Skip to content

Commit b0c0c2f

Browse files
committed
Zero-conflict launch: OS-assigned ports + single-instance lock + per-user data dir
Three layered fixes that turn AdForge launch into "click icon → app opens" with no port collisions, no double-spawn races, and no data loss on upgrade: COMMIT A — OS-assigned ports on first run - scripts/resolve-ports.cjs: new askOsForFreePair() binds to :0 (kernel- assigned ephemeral port), reads back the assigned port, releases it. Used on truly-first run (no .env.local yet) so we skip the contested 3000-range entirely. Zero collision risk because the OS just confirmed the ports are free at that exact moment. - Default port range bumped: 3010-5010 → 41573-49998 (IANA "registered but rarely used" range). New defaultStartPort() seeds from a hash of the install folder path so two AdForge installs begin at different bases. - AdForge.bat + AdForge.command no longer seed .env.local with 3005/3006 — the resolver does it with OS-assigned ports. - Verified locally: first run → OS-assigned 57828/57829. Second run reads from saved .env.local. Zero hits in the 3000-range. COMMIT B — Single-instance lock + browser auto-refresh - scripts/local-sync.cjs: acquireSingletonLock() writes pid to data/.adforge.pid at boot. If another sidecar from this install is already running (pid alive per signal-0 check), exits immediately. Prevents the "user double-clicks AdForge.bat twice" race where two processes both try to writeEnvLocal. - New SESSION_TOKEN bumps on every sidecar start. /health endpoint exposes it. public/launcher.html polls every 2s; when the token changes the page reloads itself. Effect: user relaunches AdForge (or reboots their machine and restarts the sidecar) → existing browser tab refreshes automatically. No manual F5. COMMIT C — Per-user data dir (OS conventions) - scripts/local-sync.cjs: DATA_DIR now resolves to OS-native config: Windows: %APPDATA%\AdForge\ macOS: ~/Library/Application Support/AdForge/ Linux: ~/.config/adforge/ (respects $XDG_CONFIG_HOME) - Override via ADFORGE_DATA_DIR env var for testing / portable setups. - One-time migrateLegacyDataDirOnce() copies any existing ./data/snapshot.json from the install folder to the new location. The legacy file is not deleted (breadcrumb if user wants it). - Result: user can re-clone AdForge into a new folder or replace their install — saved snapshot persists. Aligns with native-app conventions. - Verified locally: legacy snapshot was migrated to C:\Users\princ\AppData\Roaming\AdForge\snapshot.json on first run. Verification: tsc clean, 43/43 unit tests pass, next build compiles. The existing 47/47 Playwright smoke tests still apply (routes weren't touched).
1 parent 7fd6c23 commit b0c0c2f

5 files changed

Lines changed: 191 additions & 20 deletions

File tree

AdForge.bat

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,9 @@ if not exist node_modules (
4343
exit /b 1
4444
)
4545
)
46-
if not exist .env.local (
47-
> .env.local echo # AdForge configuration ^(default - resolve-ports.cjs may shift if conflicts^)
48-
>> .env.local echo PORT=3005
49-
>> .env.local echo ADFORGE_SYNC_PORT=3006
50-
)
46+
REM .env.local is NOT seeded here — the port resolver below detects first-run
47+
REM (no .env.local) and asks the OS for ports the kernel just confirmed are
48+
REM free. That avoids the 3000-range collision storm entirely.
5149
if not exist data mkdir data
5250

5351
REM Desktop shortcut on first run

AdForge.command

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,10 @@ if [ ! -d node_modules ]; then
2828
npm install --no-audit --no-fund
2929
fi
3030

31-
# 3. Default .env.local (resolve-ports.cjs may shift these later if conflicts)
32-
if [ ! -f .env.local ]; then
33-
cat > .env.local <<EOF
34-
# AdForge configuration (default - resolve-ports.cjs may shift if conflicts)
35-
PORT=3005
36-
ADFORGE_SYNC_PORT=3006
37-
EOF
38-
fi
31+
# .env.local is NOT seeded here — the port resolver below detects first-run
32+
# (no .env.local) and asks the OS for ports the kernel just confirmed are
33+
# free. That skips the contested 3000-range entirely so we never collide
34+
# with Next.js / Vite / Express / Rails / Flask running locally.
3935

4036
mkdir -p data
4137

public/launcher.html

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,31 @@ <h2>Data</h2>
513513
loadConfig();
514514
poll();
515515
setInterval(poll, 1500);
516+
517+
// Browser auto-refresh: when the user relaunches AdForge from the desktop,
518+
// the sidecar restarts with a fresh SESSION_TOKEN. Detect the change here
519+
// so an already-open tab reloads itself — turns "click app icon" into
520+
// "app opens / refreshes" with no manual F5.
521+
let knownSession = null;
522+
async function checkSession() {
523+
try {
524+
const r = await fetch("/health", { cache: "no-store" });
525+
if (!r.ok) return;
526+
const h = await r.json();
527+
if (!h?.session_token) return;
528+
if (knownSession === null) {
529+
knownSession = h.session_token;
530+
return;
531+
}
532+
if (h.session_token !== knownSession) {
533+
knownSession = h.session_token;
534+
// Soft reload — preserve scroll, keep form state if any.
535+
window.location.reload();
536+
}
537+
} catch {}
538+
}
539+
setInterval(checkSession, 2000);
540+
checkSession();
516541
})();
517542
</script>
518543
</body>

scripts/local-sync.cjs

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,49 @@ const { spawn } = require("child_process");
3333

3434
const PORT = Number(process.env.ADFORGE_SYNC_PORT || process.env.ADOS_SYNC_PORT || 3006);
3535
const PROJECT_ROOT = path.resolve(__dirname, "..");
36-
const DATA_DIR = path.join(PROJECT_ROOT, "data");
36+
// Per-user data dir: snapshots + lock file live in the OS's user-level
37+
// config dir (%APPDATA%\AdForge on Windows, ~/Library/Application Support
38+
// /AdForge on macOS, ~/.config/adforge on Linux). This means upgrading
39+
// AdForge — even re-cloning into a new folder — never loses the user's
40+
// saved snapshot. Aligns with native-app conventions.
41+
//
42+
// First boot migrates any legacy ./data/snapshot.json from the install
43+
// folder into the new location so existing users don't lose state.
44+
function resolveUserDataDir() {
45+
// Allow override via env for testing / unusual setups.
46+
if (process.env.ADFORGE_DATA_DIR) return process.env.ADFORGE_DATA_DIR;
47+
const plat = process.platform;
48+
const home = os.homedir();
49+
if (plat === "win32") {
50+
return path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "AdForge");
51+
}
52+
if (plat === "darwin") {
53+
return path.join(home, "Library", "Application Support", "AdForge");
54+
}
55+
// Linux + others: XDG_CONFIG_HOME or fallback to ~/.config
56+
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, ".config"), "adforge");
57+
}
58+
59+
const DATA_DIR = resolveUserDataDir();
60+
const LEGACY_DATA_DIR = path.join(PROJECT_ROOT, "data");
3761
const SNAPSHOT_PATH = path.join(DATA_DIR, "snapshot.json");
62+
63+
// One-time migration: if the user has a legacy ./data/snapshot.json from a
64+
// previous AdForge version AND no new-location snapshot yet, move it.
65+
function migrateLegacyDataDirOnce() {
66+
try {
67+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
68+
const legacy = path.join(LEGACY_DATA_DIR, "snapshot.json");
69+
if (fs.existsSync(legacy) && !fs.existsSync(SNAPSHOT_PATH)) {
70+
fs.copyFileSync(legacy, SNAPSHOT_PATH);
71+
// Don't delete the legacy file — leaves a breadcrumb if user wants it.
72+
console.log(`[adforge] migrated legacy snapshot ${legacy}${SNAPSHOT_PATH}`);
73+
}
74+
} catch (e) {
75+
console.warn(`[adforge] data-dir migration failed (non-fatal): ${e?.message ?? e}`);
76+
}
77+
}
78+
migrateLegacyDataDirOnce();
3879
const LAUNCHER_HTML = path.join(PROJECT_ROOT, "public", "launcher.html");
3980
const ENV_LOCAL = path.join(PROJECT_ROOT, ".env.local");
4081
const MAX_BODY = 25 * 1024 * 1024;
@@ -620,7 +661,8 @@ const server = http.createServer(async (req, res) => {
620661
port: PORT,
621662
cwd: PROJECT_ROOT,
622663
capabilities: ["status", "snapshot", "config", "web/start", "web/stop", "web/restart", "web/rebuild", "diagnostics", "update/check", "update/apply", "update/status", "ingest"],
623-
sidecar_version: "2026.05.ef96b97+", // bump this when adding new endpoints
664+
sidecar_version: "2026.05.7fd6c23+", // bump this when adding new endpoints
665+
session_token: SESSION_TOKEN,
624666
});
625667
}
626668

@@ -923,6 +965,47 @@ const server = http.createServer(async (req, res) => {
923965
// before the user clicks Start.
924966
try { webPort = envPort(readEnvLocal().PORT, 3005); } catch {}
925967

968+
// Single-instance lock. If another sidecar from THIS install is already
969+
// running (same PID still alive), exit early — the launcher script will
970+
// open the browser. Prevents the "user double-clicks AdForge.bat twice in
971+
// 2 seconds" race where two processes both try to writeEnvLocal.
972+
const LOCK_FILE = path.join(DATA_DIR, ".adforge.pid");
973+
function acquireSingletonLock() {
974+
try {
975+
if (fs.existsSync(LOCK_FILE)) {
976+
const oldPid = Number(fs.readFileSync(LOCK_FILE, "utf8").trim());
977+
if (oldPid > 0) {
978+
try {
979+
// Signal 0 → exists check, throws ESRCH if dead. Cross-platform.
980+
process.kill(oldPid, 0);
981+
// Process alive → another instance owns the lock.
982+
console.error(`[adforge] another sidecar (pid ${oldPid}) is already running for this install. Exiting.`);
983+
process.exit(0);
984+
} catch {
985+
// Process dead → stale lock, safe to take over.
986+
}
987+
}
988+
}
989+
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
990+
fs.writeFileSync(LOCK_FILE, String(process.pid), "utf8");
991+
const cleanup = () => { try { fs.unlinkSync(LOCK_FILE); } catch {} };
992+
process.on("exit", cleanup);
993+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
994+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
995+
} catch (e) {
996+
// Non-fatal — if the lockfile can't be created, fall through and let
997+
// listen() fail with EADDRINUSE if there really is a duplicate.
998+
console.warn(`[adforge] could not acquire singleton lock: ${e?.message ?? e}`);
999+
}
1000+
}
1001+
acquireSingletonLock();
1002+
1003+
// Session token bumps every time the sidecar starts. The browser polls
1004+
// /health (via /status), sees a new SESSION_TOKEN, and reloads itself.
1005+
// That means "user clicks AdForge.bat after a system restart" gives them
1006+
// a fresh page in the already-open browser tab — no manual refresh needed.
1007+
const SESSION_TOKEN = String(Date.now());
1008+
9261009
// Surface EADDRINUSE clearly — resolve-ports.cjs has a TOCTOU window between
9271010
// the port-free probe and listen(). If another process grabs the port in that
9281011
// window, this is the only place the user gets a clear message. (Audit finding #42.)

scripts/resolve-ports.cjs

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,29 @@ const http = require("http");
3030
const PROJECT_ROOT = path.resolve(__dirname, "..");
3131
const ENV_LOCAL = path.join(PROJECT_ROOT, ".env.local");
3232

33+
// Default port range: 41573-49999. Chosen because:
34+
// - 3000-9000 is the most-collided range (every Next.js / Express / Rails /
35+
// Flask / Vite dev server lives there). New AdForge users hit collisions
36+
// immediately and the resolver has to shift on every launch.
37+
// - 41573+ is in IANA's "registered but rarely used" range. Almost nothing
38+
// else binds there.
39+
// - We derive a starting offset from a hash of the install folder so two
40+
// AdForge installs in different folders begin at different bases and
41+
// don't race to claim the same pair before the OS-probe step.
42+
function defaultStartPort() {
43+
let h = 0;
44+
for (const ch of PROJECT_ROOT) {
45+
h = ((h << 5) - h + ch.charCodeAt(0)) | 0;
46+
}
47+
// 41573-49998 in 8425-port band (yields 4212 pair slots)
48+
const offset = Math.abs(h) % 8424;
49+
return 41573 + (offset & ~1); // even alignment
50+
}
51+
52+
const DEFAULT_PORT_RANGE_START = defaultStartPort();
53+
3354
function readEnv() {
34-
const out = { PORT: "3005", ADFORGE_SYNC_PORT: "3006" };
55+
const out = { PORT: String(DEFAULT_PORT_RANGE_START), ADFORGE_SYNC_PORT: String(DEFAULT_PORT_RANGE_START + 1) };
3556
if (!fs.existsSync(ENV_LOCAL)) return out;
3657
for (const line of fs.readFileSync(ENV_LOCAL, "utf8").split(/\r?\n/)) {
3758
const t = line.trim();
@@ -80,6 +101,39 @@ function isPortFree(port) {
80101
});
81102
}
82103

104+
/** Ask the OS for an ephemeral free port. Binds to :0, reads the assigned
105+
* port, immediately releases it. Used on truly-first run so we never even
106+
* start in the contested 3000-range. */
107+
function askOsForFreePort() {
108+
return new Promise((resolve) => {
109+
const srv = net.createServer();
110+
srv.once("error", () => resolve(null));
111+
srv.listen(0, "127.0.0.1", () => {
112+
const addr = srv.address();
113+
const port = (addr && typeof addr === "object") ? addr.port : null;
114+
srv.close(() => resolve(port));
115+
});
116+
});
117+
}
118+
119+
/** Get two consecutive ports the OS believes are free. We can't ask for
120+
* consecutive ports atomically, so: ask for one, then walk +1 / +2 until we
121+
* find an adjacent free port. Almost always succeeds on the first try. */
122+
async function askOsForFreePair() {
123+
for (let attempt = 0; attempt < 10; attempt++) {
124+
const base = await askOsForFreePort();
125+
if (!base) continue;
126+
// Prefer even base so web port stays even, sync stays odd.
127+
const web = base % 2 === 0 ? base : base - 1;
128+
const sync = web + 1;
129+
if (web < 1024) continue; // skip privileged ports
130+
if (await isPortFree(web) && await isPortFree(sync)) {
131+
return { web, sync };
132+
}
133+
}
134+
return null;
135+
}
136+
83137
async function findFreePair(startFrom) {
84138
// Start at startFrom (rounded up to even), walk in pairs. The web port is
85139
// even, the sync port is odd — keeps the relationship obvious.
@@ -100,9 +154,24 @@ function normalize(p) {
100154

101155
(async () => {
102156
try {
157+
// First-run path: no .env.local yet → ask the OS for two ports the kernel
158+
// just confirmed are free at this moment. Zero collision risk because we
159+
// skip the contested 3000-range entirely.
160+
const firstRun = !fs.existsSync(ENV_LOCAL);
161+
if (firstRun) {
162+
const fresh = await askOsForFreePair();
163+
if (fresh) {
164+
writeEnv({ PORT: String(fresh.web), ADFORGE_SYNC_PORT: String(fresh.sync) });
165+
process.stdout.write(`ACTION=start PORT=${fresh.web} SYNC=${fresh.sync} REASON=first_run_os_assigned\n`);
166+
return;
167+
}
168+
// OS-port-probe failed (extremely rare). Fall through to hash-based
169+
// default which still avoids the 3000-range.
170+
}
171+
103172
const env = readEnv();
104-
const desiredWeb = Number(env.PORT) || 3005;
105-
const desiredSync = Number(env.ADFORGE_SYNC_PORT) || 3006;
173+
const desiredWeb = Number(env.PORT) || DEFAULT_PORT_RANGE_START;
174+
const desiredSync = Number(env.ADFORGE_SYNC_PORT) || (DEFAULT_PORT_RANGE_START + 1);
106175

107176
const health = await probeHealth(desiredSync);
108177
const ourCwd = normalize(PROJECT_ROOT);
@@ -126,7 +195,7 @@ function normalize(p) {
126195
// It's SOMEONE ELSE'S sidecar on the port we wanted (theirCwd mismatch,
127196
// OR theirCwd missing because they're running an older sidecar that
128197
// doesn't return cwd). Either way, leave them alone and pick a new pair.
129-
const pair = await findFreePair(Math.max(3010, desiredSync + 2));
198+
const pair = await findFreePair(Math.max(DEFAULT_PORT_RANGE_START, desiredSync + 2));
130199
if (!pair) {
131200
process.stdout.write(`ACTION=error REASON=no_free_port_pair\n`);
132201
return;
@@ -146,7 +215,7 @@ function normalize(p) {
146215

147216
// One or both ports are bound by something non-AdForge (a different web app,
148217
// a stalled process, etc.). Shift to the next free pair.
149-
const pair = await findFreePair(Math.max(3010, desiredSync + 2));
218+
const pair = await findFreePair(Math.max(DEFAULT_PORT_RANGE_START, desiredSync + 2));
150219
if (!pair) {
151220
process.stdout.write(`ACTION=error REASON=no_free_port_pair\n`);
152221
return;

0 commit comments

Comments
 (0)