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.
- package/package.json +1 -1
- package/serve.js +154 -7
- package/serve.ts +154 -7
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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 () => {
|