rechrome 1.29.0 → 1.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/serve.js +154 -7
  3. package/serve.ts +154 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rechrome",
3
- "version": "1.29.0",
3
+ "version": "1.29.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/snomiao/rechrome.git"
package/serve.js CHANGED
@@ -243,6 +243,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
243
243
  return nameOrEmail;
244
244
  }
245
245
 
246
+ // Free the listening port from stale daemon holders before retrying a failed bind.
247
+ // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
248
+ // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
249
+ // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
250
+ // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
251
+ // only happens when such a stale holder exists; killing orphaned daemon holders here is
252
+ // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
253
+ // Chrome tabs persist regardless — the cliDaemon only drives them).
254
+ // Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
255
+ // THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
256
+ // Playwright daemon, which runs from its own node_modules.
257
+ const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
258
+ const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
259
+ const RECH_SERVE = String.raw`rech(rome)?(\.js)?"?\s+serve`;
260
+ const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
261
+ // A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
262
+ // responds at an address is a live server there — possibly a healthy rech serve — so it is
263
+ // never killed. Both schemes: the holder's TLS setting may differ from this serve's.
264
+ async function answersAt(address: string, port: number): Promise<boolean> {
265
+ const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
266
+ : address === "::" ? "[::1]"
267
+ : address.includes(":") ? `[${address}]` : address;
268
+ const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
269
+ signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
270
+ } as RequestInit).then(() => true, () => false);
271
+ return (await Promise.all([probe("http"), probe("https")])).includes(true);
272
+ }
273
+
274
+ type PortHolder = { address: string; pid: number; alive: boolean; command: string };
275
+
276
+ // Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
277
+ // its family (a wildcard bind conflicts with every address of the family). Never this serve's
278
+ // own sockets, which may already hold other addresses on the same port from this startup.
279
+ function conflictingHolders(port: number, host: string): PortHolder[] {
280
+ let holders: PortHolder[] = [];
281
+ if (process.platform === "win32") {
282
+ const ps = [
283
+ "$ErrorActionPreference='SilentlyContinue';",
284
+ `$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
285
+ " $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
286
+ " [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
287
+ "ConvertTo-Json -Compress -InputObject $r",
288
+ ].join(" ");
289
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
290
+ try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
291
+ } else {
292
+ const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
293
+ let pid = 0;
294
+ for (const line of out.split("\n")) {
295
+ if (line.startsWith("p")) pid = Number(line.slice(1));
296
+ else if (line.startsWith("n") && pid) {
297
+ const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
298
+ const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
299
+ holders.push({ address, pid, alive: true, command });
300
+ }
301
+ }
302
+ }
303
+ const v6 = (a: string) => a.includes(":");
304
+ const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
305
+ // A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
306
+ return holders.filter(h => h.pid !== process.pid && (
307
+ wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
308
+ : h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
309
+ }
310
+
311
+ // Free the conflicting address from stale rech holders before retrying a failed bind.
312
+ // Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
313
+ // cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
314
+ // server; (2) only if the address is STILL held by owners that are all dead — the
315
+ // inherited-handle case, where the socket lives in a child while netstat attributes it to a
316
+ // now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
317
+ // the case (the live holder can't be mapped from the port), so it is a logged last resort.
318
+ async function freeStalePort(port: number, host: string): Promise<void> {
319
+ try {
320
+ const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
321
+ for (const h of conflictingHolders(port, host)) {
322
+ if (!h.alive) continue;
323
+ if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
324
+ if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
325
+ log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
326
+ try { process.kill(h.pid, "SIGKILL"); } catch {}
327
+ }
328
+ await new Promise(r => setTimeout(r, 400));
329
+ const remaining = conflictingHolders(port, host);
330
+ if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
331
+ const q = (text: string) => text.replaceAll("'", "''");
332
+ const ps = [
333
+ "$ErrorActionPreference='SilentlyContinue';",
334
+ // A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
335
+ // can't be told from an orphan by ancestry. They are only provably orphaned when no
336
+ // other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
337
+ `$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
338
+ `$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
339
+ "if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
340
+ "else {",
341
+ // Exclude this PowerShell itself: its own command line contains the pattern.
342
+ ` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
343
+ " Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
344
+ " $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
345
+ "}",
346
+ ].join(" ");
347
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
348
+ if (out) log(out);
349
+ }
350
+ } catch {
351
+ // best effort — the retry will surface a clear error if the port is still held
352
+ }
353
+ await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
354
+ }
355
+
246
356
  // --- Foreground/orphan self-exit ---------------------------------------------------
247
357
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
248
358
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -394,9 +504,13 @@ export async function serve() {
394
504
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
395
505
  const policies = new Map<string, Listener>();
396
506
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
397
- const startServer = (initial: Listener) => Bun.serve({
507
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
398
508
  hostname: initial.host,
399
509
  port: initial.port,
510
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
511
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
512
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
513
+ reusePort,
400
514
  tls,
401
515
  error(err) {
402
516
  log(`unhandled error: ${err.message}`);
@@ -773,14 +887,47 @@ export async function serve() {
773
887
  },
774
888
  });
775
889
 
890
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
891
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
892
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
893
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
894
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
895
+ // Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
896
+ // safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
897
+ // reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
898
+ // than a permanent EADDRINUSE crash-loop.
899
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
900
+ const MAX_BIND_ATTEMPTS = 4;
901
+ const bindAtStartup = async (listener: Listener) => {
902
+ for (let attempt = 1; ; attempt++) {
903
+ try {
904
+ return startServer(listener);
905
+ } catch (e: any) {
906
+ if (!isEaddrInUse(e)) throw e;
907
+ // Something answers at this exact address: a live server (never killed, never port-shared).
908
+ if (await answersAt(listener.host, listener.port)) {
909
+ log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
910
+ throw e;
911
+ }
912
+ if (attempt === MAX_BIND_ATTEMPTS) {
913
+ log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
914
+ return startServer(listener, true);
915
+ }
916
+ log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
917
+ await freeStalePort(listener.port, listener.host);
918
+ }
919
+ }
920
+ };
921
+
776
922
  // Stage all new sockets before changing policy. Bind failure leaves existing
777
- // listeners and browser sessions intact; never kill a process holding another port.
778
- const reconcile = async (listeners: Listener[]) => {
923
+ // listeners and browser sessions intact; never kill a process holding another port
924
+ // once serving (stale-holder recovery runs only at startup).
925
+ const reconcile = async (listeners: Listener[], startup = false) => {
779
926
  const staged = new Map<string, ReturnType<typeof Bun.serve>>();
780
927
  try {
781
928
  for (const listener of listeners) {
782
929
  const address = listenerAddress(listener);
783
- if (!servers.has(address)) staged.set(address, startServer(listener));
930
+ if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
784
931
  }
785
932
  } catch (error) {
786
933
  for (const server of staged.values()) server.stop(true);
@@ -795,17 +942,17 @@ export async function serve() {
795
942
  };
796
943
  let applied = "";
797
944
  let configured = false;
798
- const reload = async () => {
945
+ const reload = async (startup = false) => {
799
946
  const config = await readListeners();
800
947
  if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
801
948
  const listeners = config?.listeners ?? [legacy];
802
949
  const fingerprint = JSON.stringify(listeners);
803
950
  if (applied === fingerprint) return;
804
- await reconcile(listeners);
951
+ await reconcile(listeners, startup);
805
952
  configured ||= !!config;
806
953
  applied = fingerprint;
807
954
  };
808
- await reload();
955
+ await reload(true);
809
956
  let reloading = false;
810
957
  let lastError = "";
811
958
  setInterval(async () => {
package/serve.ts CHANGED
@@ -243,6 +243,116 @@ async function resolveProfileDirectory(nameOrEmail: string): Promise<string> {
243
243
  return nameOrEmail;
244
244
  }
245
245
 
246
+ // Free the listening port from stale daemon holders before retrying a failed bind.
247
+ // On Windows the listening socket (created inheritable by Bun.serve) is swept into the
248
+ // detached cliDaemon grandchild via bInheritHandles, so an orphaned cliDaemon from a
249
+ // previous `serve` keeps the port in LISTEN after the old serve dies — the fresh serve
250
+ // then crash-loops on EADDRINUSE. A clean restart releases the port, so a failed bind
251
+ // only happens when such a stale holder exists; killing orphaned daemon holders here is
252
+ // safe because a freshly-starting serve owns no live sessions of its own yet (the user's
253
+ // Chrome tabs persist regardless — the cliDaemon only drives them).
254
+ // Command lines freeStalePort may kill: a previous rech serve, or a cliDaemon started from
255
+ // THIS install's playwright (lib/ or vendor/ under this directory) — never another app's
256
+ // Playwright daemon, which runs from its own node_modules.
257
+ const escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
258
+ const OWN_CLI_DAEMON = import.meta.dir.split(/[\\/]/).map(escapeRegex).join(String.raw`[\\/]`) + String.raw`[\\/].*cliDaemon\.js`;
259
+ const RECH_SERVE = String.raw`rech(rome)?(\.ts)?"?\s+serve`;
260
+ const STALE_HOLDER_PATTERN = `${OWN_CLI_DAEMON}|${RECH_SERVE}`;
261
+ // A leaked socket (or a wedged serve) accepts connections but never answers. Anything that
262
+ // responds at an address is a live server there — possibly a healthy rech serve — so it is
263
+ // never killed. Both schemes: the holder's TLS setting may differ from this serve's.
264
+ async function answersAt(address: string, port: number): Promise<boolean> {
265
+ const host = address === "0.0.0.0" || address === "*" ? "127.0.0.1"
266
+ : address === "::" ? "[::1]"
267
+ : address.includes(":") ? `[${address}]` : address;
268
+ const probe = (scheme: string) => fetch(`${scheme}://${host}:${port}/`, {
269
+ signal: AbortSignal.timeout(1500), tls: { rejectUnauthorized: false },
270
+ } as RequestInit).then(() => true, () => false);
271
+ return (await Promise.all([probe("http"), probe("https")])).includes(true);
272
+ }
273
+
274
+ type PortHolder = { address: string; pid: number; alive: boolean; command: string };
275
+
276
+ // Listeners on `port` that conflict with binding `host`: the same address, or a wildcard of
277
+ // its family (a wildcard bind conflicts with every address of the family). Never this serve's
278
+ // own sockets, which may already hold other addresses on the same port from this startup.
279
+ function conflictingHolders(port: number, host: string): PortHolder[] {
280
+ let holders: PortHolder[] = [];
281
+ if (process.platform === "win32") {
282
+ const ps = [
283
+ "$ErrorActionPreference='SilentlyContinue';",
284
+ `$r=@(Get-NetTCPConnection -LocalPort ${port} -State Listen | ForEach-Object {`,
285
+ " $p=Get-CimInstance Win32_Process -Filter \"ProcessId=$($_.OwningProcess)\";",
286
+ " [pscustomobject]@{ address=[string]$_.LocalAddress; pid=[int]$_.OwningProcess; alive=[bool]$p; command=[string]$p.CommandLine } });",
287
+ "ConvertTo-Json -Compress -InputObject $r",
288
+ ].join(" ");
289
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
290
+ try { holders = out ? [JSON.parse(out)].flat() : []; } catch { holders = []; }
291
+ } else {
292
+ const out = Bun.spawnSync(["sh", "-c", `lsof -nP -iTCP:${port} -sTCP:LISTEN -Fpn 2>/dev/null`]).stdout?.toString() ?? "";
293
+ let pid = 0;
294
+ for (const line of out.split("\n")) {
295
+ if (line.startsWith("p")) pid = Number(line.slice(1));
296
+ else if (line.startsWith("n") && pid) {
297
+ const address = line.slice(1).replace(/:\d+$/, "").replace(/^\[(.*)\]$/, "$1");
298
+ const command = Bun.spawnSync(["ps", "-o", "command=", "-p", String(pid)]).stdout?.toString().trim() ?? "";
299
+ holders.push({ address, pid, alive: true, command });
300
+ }
301
+ }
302
+ }
303
+ const v6 = (a: string) => a.includes(":");
304
+ const wildcard = (a: string) => a === "0.0.0.0" || a === "::" || a === "*";
305
+ // A `::` listener is dual-stack on Windows and most Linux setups: it conflicts with IPv4 binds too.
306
+ return holders.filter(h => h.pid !== process.pid && (
307
+ wildcard(host) ? (h.address === "*" || h.address === "::" || host === "::" || !v6(h.address))
308
+ : h.address === host || h.address === "*" || h.address === "::" || (h.address === "0.0.0.0" && !v6(host))));
309
+ }
310
+
311
+ // Free the conflicting address from stale rech holders before retrying a failed bind.
312
+ // Narrow-first: (1) kill each conflicting owner that is a live rech serve / this install's
313
+ // cliDaemon AND doesn't answer at its own address — never an unrelated app, never a healthy
314
+ // server; (2) only if the address is STILL held by owners that are all dead — the
315
+ // inherited-handle case, where the socket lives in a child while netstat attributes it to a
316
+ // now-dead owner — sweep orphaned cliDaemons (Windows). That sweep is the only recovery for
317
+ // the case (the live holder can't be mapped from the port), so it is a logged last resort.
318
+ async function freeStalePort(port: number, host: string): Promise<void> {
319
+ try {
320
+ const stale = new RegExp(STALE_HOLDER_PATTERN, process.platform === "win32" ? "i" : "");
321
+ for (const h of conflictingHolders(port, host)) {
322
+ if (!h.alive) continue;
323
+ if (!stale.test(h.command)) { log(`freeStalePort: port ${port} is held by an unrelated process (${h.pid}); not killing it`); continue; }
324
+ if (await answersAt(h.address, port)) { log(`freeStalePort: ${h.address}:${port} is served by a live process (${h.pid}); not killing it`); continue; }
325
+ log(`freeStalePort: killing stale rech holder ${h.pid} of ${h.address}:${port}`);
326
+ try { process.kill(h.pid, "SIGKILL"); } catch {}
327
+ }
328
+ await new Promise(r => setTimeout(r, 400));
329
+ const remaining = conflictingHolders(port, host);
330
+ if (process.platform === "win32" && remaining.length && remaining.every(h => !h.alive)) {
331
+ const q = (text: string) => text.replaceAll("'", "''");
332
+ const ps = [
333
+ "$ErrorActionPreference='SilentlyContinue';",
334
+ // A cliDaemon's parent (playwright-cli) exits right after spawning it, so a live one
335
+ // can't be told from an orphan by ancestry. They are only provably orphaned when no
336
+ // other rech serve is running: exclude this serve and its own wrappers (oxmgr, cmd).
337
+ `$self=@(); $x=${process.pid}; while($x){ $self+=$x; $x=(Get-CimInstance Win32_Process -Filter \"ProcessId=$x\").ParentProcessId; if($self -contains $x){ break } };`,
338
+ `$live=@(Get-CimInstance Win32_Process | Where-Object { $self -notcontains $_.ProcessId -and $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(RECH_SERVE)}' });`,
339
+ "if($live.Count){ Write-Output (\"freeStalePort: port still held, but another rech serve is running (\" + ($live.ProcessId -join ',') + \"); not sweeping cliDaemons\") }",
340
+ "else {",
341
+ // Exclude this PowerShell itself: its own command line contains the pattern.
342
+ ` $d=Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $PID -and $_.CommandLine -match '${q(OWN_CLI_DAEMON)}' };`,
343
+ " Write-Output (\"freeStalePort: port still held; killing orphaned cliDaemon holders: \" + ($d.ProcessId -join ','));",
344
+ " $d | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
345
+ "}",
346
+ ].join(" ");
347
+ const out = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true }).stdout?.toString().trim();
348
+ if (out) log(out);
349
+ }
350
+ } catch {
351
+ // best effort — the retry will surface a clear error if the port is still held
352
+ }
353
+ await new Promise(r => setTimeout(r, 800)); // let the OS release the socket before retry
354
+ }
355
+
246
356
  // --- Foreground/orphan self-exit ---------------------------------------------------
247
357
  // A foreground `rech serve` (run directly by an agent, NOT under oxmgr/pm2) has no
248
358
  // process-manager safety net: when the agent that spawned it exits, the OS re-parents
@@ -394,9 +504,13 @@ export async function serve() {
394
504
  const legacy: Listener = { name: "legacy", host: listenHost, port, key, profiles: "*" };
395
505
  const policies = new Map<string, Listener>();
396
506
  const servers = new Map<string, ReturnType<typeof Bun.serve>>();
397
- const startServer = (initial: Listener) => Bun.serve({
507
+ const startServer = (initial: Listener, reusePort = false) => Bun.serve({
398
508
  hostname: initial.host,
399
509
  port: initial.port,
510
+ // reusePort is used only as a last-resort fallback (see bindAtStartup below): if an orphaned
511
+ // holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
512
+ // instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
513
+ reusePort,
400
514
  tls,
401
515
  error(err) {
402
516
  log(`unhandled error: ${err.message}`);
@@ -773,14 +887,47 @@ export async function serve() {
773
887
  },
774
888
  });
775
889
 
890
+ // A leaked listening-socket handle in an orphaned cliDaemon can keep a port in LISTEN after a
891
+ // prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
892
+ // detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
893
+ // netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
894
+ // map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
895
+ // Startup only: a freshly-starting serve owns no live sessions, so clearing stale holders is
896
+ // safe. Hot reloads never kill anything (see reconcile). As an absolute last resort, bind with
897
+ // reusePort so a holder we genuinely can't kill degrades to "up but sharing the port" rather
898
+ // than a permanent EADDRINUSE crash-loop.
899
+ const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
900
+ const MAX_BIND_ATTEMPTS = 4;
901
+ const bindAtStartup = async (listener: Listener) => {
902
+ for (let attempt = 1; ; attempt++) {
903
+ try {
904
+ return startServer(listener);
905
+ } catch (e: any) {
906
+ if (!isEaddrInUse(e)) throw e;
907
+ // Something answers at this exact address: a live server (never killed, never port-shared).
908
+ if (await answersAt(listener.host, listener.port)) {
909
+ log(`port ${listener.port} is held by a live server that answers requests — not touching it`);
910
+ throw e;
911
+ }
912
+ if (attempt === MAX_BIND_ATTEMPTS) {
913
+ log(`port ${listener.port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
914
+ return startServer(listener, true);
915
+ }
916
+ log(`port ${listener.port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
917
+ await freeStalePort(listener.port, listener.host);
918
+ }
919
+ }
920
+ };
921
+
776
922
  // Stage all new sockets before changing policy. Bind failure leaves existing
777
- // listeners and browser sessions intact; never kill a process holding another port.
778
- const reconcile = async (listeners: Listener[]) => {
923
+ // listeners and browser sessions intact; never kill a process holding another port
924
+ // once serving (stale-holder recovery runs only at startup).
925
+ const reconcile = async (listeners: Listener[], startup = false) => {
779
926
  const staged = new Map<string, ReturnType<typeof Bun.serve>>();
780
927
  try {
781
928
  for (const listener of listeners) {
782
929
  const address = listenerAddress(listener);
783
- if (!servers.has(address)) staged.set(address, startServer(listener));
930
+ if (!servers.has(address)) staged.set(address, startup ? await bindAtStartup(listener) : startServer(listener));
784
931
  }
785
932
  } catch (error) {
786
933
  for (const server of staged.values()) server.stop(true);
@@ -795,17 +942,17 @@ export async function serve() {
795
942
  };
796
943
  let applied = "";
797
944
  let configured = false;
798
- const reload = async () => {
945
+ const reload = async (startup = false) => {
799
946
  const config = await readListeners();
800
947
  if (!config && configured) throw new Error("listeners.json disappeared; refusing to restore unrestricted legacy access");
801
948
  const listeners = config?.listeners ?? [legacy];
802
949
  const fingerprint = JSON.stringify(listeners);
803
950
  if (applied === fingerprint) return;
804
- await reconcile(listeners);
951
+ await reconcile(listeners, startup);
805
952
  configured ||= !!config;
806
953
  applied = fingerprint;
807
954
  };
808
- await reload();
955
+ await reload(true);
809
956
  let reloading = false;
810
957
  let lastError = "";
811
958
  setInterval(async () => {