rechrome 1.20.0 → 1.22.0
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/rech.js +185 -61
- package/rech.ts +185 -61
- package/serve.js +231 -40
- package/serve.ts +231 -40
package/serve.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { file } from "bun";
|
|
2
2
|
import { createHash, X509Certificate } from "crypto";
|
|
3
|
-
import { mkdirSync, unlinkSync, accessSync, constants as fsConstants } from "fs";
|
|
3
|
+
import { mkdirSync, unlinkSync, accessSync, readdirSync, constants as fsConstants } from "fs";
|
|
4
4
|
import { join, resolve, relative, isAbsolute } from "path";
|
|
5
5
|
import {
|
|
6
6
|
log,
|
|
@@ -16,25 +16,121 @@ import {
|
|
|
16
16
|
const TAILSCALE_BIN = process.env.TAILSCALE_BIN || "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
|
|
17
17
|
const CERT_RENEW_THRESHOLD_DAYS = 7;
|
|
18
18
|
|
|
19
|
-
// Short label for a client identity, used as the Chrome tab-group name (the tab
|
|
20
|
-
//
|
|
21
|
-
// -> "
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
// Short label for a client identity, used as the Chrome tab-group name (the tab strip is
|
|
20
|
+
// space-constrained, so cap at 7 chars). Handles the current label shape and the legacy gitUrl:
|
|
21
|
+
// "host/owner/repo#<basename>@<branch>" -> "bas:bra" (3+3) (current)
|
|
22
|
+
// "host/owner/repo/tree/branch" -> "rep:bra" (3+3) (legacy gitUrl)
|
|
23
|
+
// "host:/path/to/dir" -> "dir" (non-git)
|
|
24
|
+
// bare host/IP -> as-is
|
|
25
|
+
export const MAX_GROUP_LABEL_LEN = 7;
|
|
26
|
+
export function shortClientLabel(raw: string): string {
|
|
25
27
|
if (!raw) return raw;
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const hostCwd = baseId.match(/^[^:]+:(.+)$/);
|
|
33
|
-
label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || baseId) : baseId;
|
|
28
|
+
const join3 = (a: string, b?: string) => (b ? `${a.slice(0, 3)}:${b.slice(0, 3)}` : a);
|
|
29
|
+
// Current label: "[remote#]<worktree-basename>[@<branch>]"
|
|
30
|
+
if (raw.includes("#") || (raw.includes("@") && !raw.startsWith("http"))) {
|
|
31
|
+
const afterHash = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1) : raw;
|
|
32
|
+
const [base, branch] = afterHash.split("@");
|
|
33
|
+
return join3(base, branch).slice(0, MAX_GROUP_LABEL_LEN);
|
|
34
34
|
}
|
|
35
|
+
// Legacy gitUrl: ".../owner/repo/tree/branch"
|
|
36
|
+
const git = raw.match(/^https?:\/\/[^/]+\/[^/]+\/([^/]+?)(?:\/tree\/(.+))?$/);
|
|
37
|
+
if (git) return join3(git[1], git[2]).slice(0, MAX_GROUP_LABEL_LEN);
|
|
38
|
+
// "host:/path/to/dir" -> basename
|
|
39
|
+
const hostCwd = raw.match(/^[^:]+:(.+)$/);
|
|
40
|
+
const label = hostCwd ? (hostCwd[1].split("/").filter(Boolean).pop() || raw) : raw;
|
|
35
41
|
return label.slice(0, MAX_GROUP_LABEL_LEN);
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
// --isolate sessions (`rech --isolate ...` -> `-s=iso-<rand>`) are throwaway, single-flow
|
|
45
|
+
// buckets. We reap them on an idle TTL so an OAuth/login drive can't leak an orphaned browser
|
|
46
|
+
// context. A TTL (not close-on-exit-per-command) is required because the flows are multi-step
|
|
47
|
+
// (open -> click -> consent): closing after each command would break them. The reaper runs the
|
|
48
|
+
// CLI's `close` for the specific iso session only — it never touches the user's other sessions
|
|
49
|
+
// or quits Chrome.
|
|
50
|
+
const ISO_SESSION_TTL_MS = Number(process.env.RECH_ISOLATE_TTL_MS) || 15 * 60_000;
|
|
51
|
+
const ISO_REAP_INTERVAL_MS = 60_000;
|
|
52
|
+
const isoLastUsed = new Map<string, number>();
|
|
53
|
+
|
|
54
|
+
export function isIsoSession(namespacedSession: string): boolean {
|
|
55
|
+
return /(?:^|-)iso-[0-9a-f]+$/.test(namespacedSession);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- Relay health / self-heal ---------------------------------------------------------
|
|
59
|
+
// The daemon spawns a short-lived CLI child per command, but all commands share a long-lived
|
|
60
|
+
// per-session cliDaemon and a single extension↔relay path. Under sustained multi-session load
|
|
61
|
+
// the relay can wedge: navigations (`open`) hang to the 60s cap while cheap calls still return,
|
|
62
|
+
// and — critically — the wedge PERSISTS (every later command reuses the same broken cliDaemon),
|
|
63
|
+
// so historically only a manual `oxmgr restart rechrome` cleared it. We heal automatically on
|
|
64
|
+
// two layers, driven by real command timeouts (no synthetic probe → Chrome is never touched):
|
|
65
|
+
// - per-session: after SESSION_CLOSE_TIMEOUTS consecutive timeouts on ONE session, run the
|
|
66
|
+
// CLI `close` for it so the next command respawns a clean cliDaemon.
|
|
67
|
+
// - global: after WATCHDOG_TIMEOUTS consecutive timeouts across ALL sessions with no success
|
|
68
|
+
// in between (= the shared relay is dead), exit(1); oxmgr's `--restart always` respawns us
|
|
69
|
+
// clean and the extension WS reconnects. Each timeout burns 60s of wall time, so this can't
|
|
70
|
+
// spin faster than ~once/minute even under concurrent load.
|
|
71
|
+
const SESSION_CLOSE_TIMEOUTS = Number(process.env.RECH_SESSION_CLOSE_TIMEOUTS) || 2;
|
|
72
|
+
const WATCHDOG_TIMEOUTS = Number(process.env.RECH_WATCHDOG_TIMEOUTS) || 3;
|
|
73
|
+
let consecutiveTimeouts = 0; // global; reset on any non-timeout /run
|
|
74
|
+
const sessionTimeouts = new Map<string, number>(); // per-session consecutive-timeout streak
|
|
75
|
+
// Most-recently-used non-iso sessions, so the deep health probe can target something real
|
|
76
|
+
// instead of spawning a fresh session (which could open a browser window).
|
|
77
|
+
const recentSessions = new Map<string, number>();
|
|
78
|
+
function noteSession(sess: string, now: number): void {
|
|
79
|
+
recentSessions.set(sess, now);
|
|
80
|
+
if (recentSessions.size > 64) {
|
|
81
|
+
let oldestKey: string | undefined, oldestAt = Infinity;
|
|
82
|
+
for (const [k, t] of recentSessions) if (t < oldestAt) { oldestAt = t; oldestKey = k; }
|
|
83
|
+
if (oldestKey) recentSessions.delete(oldestKey);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function tmpSocketRoot(): string {
|
|
88
|
+
return `${(process.env.TMPDIR || "/tmp").replace(/\/$/, "")}/playwright-cli`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// On startup, adopt any iso-* sessions a previous daemon left behind so they still get reaped
|
|
92
|
+
// (in-memory tracking alone would miss pre-restart orphans). Best-effort: a mis-derived session
|
|
93
|
+
// just yields a harmless no-op `close`. Socket files are named with the session as their prefix.
|
|
94
|
+
function adoptOrphanedIsoSessions(): void {
|
|
95
|
+
try {
|
|
96
|
+
const root = tmpSocketRoot();
|
|
97
|
+
for (const sub of readdirSync(root)) {
|
|
98
|
+
let entries: string[];
|
|
99
|
+
try { entries = readdirSync(`${root}/${sub}`); } catch { continue; }
|
|
100
|
+
for (const f of entries) {
|
|
101
|
+
const m = f.match(/^([0-9a-f]+-iso-[0-9a-f]+)/) || f.match(/^(iso-[0-9a-f]+)/);
|
|
102
|
+
if (m && !isoLastUsed.has(m[1])) {
|
|
103
|
+
isoLastUsed.set(m[1], Date.now());
|
|
104
|
+
log(`adopted orphaned isolated session for reaping: ${m[1]}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// socket dir may not exist yet — nothing to adopt
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function reapIdleIsoSessions(bin: string, binArgs: string[], workDir: string): void {
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
for (const [sess, last] of isoLastUsed) {
|
|
116
|
+
if (now - last < ISO_SESSION_TTL_MS) continue;
|
|
117
|
+
isoLastUsed.delete(sess);
|
|
118
|
+
try {
|
|
119
|
+
Bun.spawn([bin, ...binArgs, "close", `-s=${sess}`], {
|
|
120
|
+
cwd: workDir,
|
|
121
|
+
stdin: "ignore",
|
|
122
|
+
stdout: "ignore",
|
|
123
|
+
stderr: "ignore",
|
|
124
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
125
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
126
|
+
});
|
|
127
|
+
log(`reaped idle isolated session (idle ${Math.round((now - last) / 1000)}s): ${sess}`);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
log(`reap failed for ${sess}: ${e}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
38
134
|
async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boolean> {
|
|
39
135
|
const certContent = await file(certPath).text().catch(() => null);
|
|
40
136
|
if (!certContent) return false;
|
|
@@ -46,7 +142,7 @@ async function renewCertIfNeeded(certPath: string, keyPath: string): Promise<boo
|
|
|
46
142
|
if (!domain) { log("TLS cert renewal: could not determine domain"); return false; }
|
|
47
143
|
log(`TLS cert expires in ${Math.floor(daysLeft)} days, renewing ${domain}...`);
|
|
48
144
|
const proc = Bun.spawn([TAILSCALE_BIN, "cert", "--cert-file", certPath, "--key-file", keyPath, domain], {
|
|
49
|
-
stdout: "pipe", stderr: "pipe",
|
|
145
|
+
stdout: "pipe", stderr: "pipe", windowsHide: true,
|
|
50
146
|
});
|
|
51
147
|
const [status, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
|
|
52
148
|
if (status !== 0) { log(`TLS cert renewal failed: ${stderr.trim()}`); return false; }
|
|
@@ -123,7 +219,7 @@ async function freeStalePort(port: number): Promise<void> {
|
|
|
123
219
|
" $h | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }",
|
|
124
220
|
"}",
|
|
125
221
|
].join(" ");
|
|
126
|
-
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps]);
|
|
222
|
+
const r = Bun.spawnSync(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], { windowsHide: true });
|
|
127
223
|
const out = r.stdout?.toString().trim();
|
|
128
224
|
if (out) log(out);
|
|
129
225
|
} else {
|
|
@@ -142,6 +238,13 @@ export async function serve() {
|
|
|
142
238
|
const workDir = join(RECH_DIR, "output");
|
|
143
239
|
mkdirSync(workDir, { recursive: true });
|
|
144
240
|
|
|
241
|
+
// Reap idle --isolate sessions so single-shot OAuth/login drives don't leak browser contexts.
|
|
242
|
+
adoptOrphanedIsoSessions();
|
|
243
|
+
setInterval(() => {
|
|
244
|
+
const [bin, ...binArgs] = splitCommand(resolvePlaywrightCli());
|
|
245
|
+
reapIdleIsoSessions(bin, binArgs, workDir);
|
|
246
|
+
}, ISO_REAP_INTERVAL_MS);
|
|
247
|
+
|
|
145
248
|
const listenHost = process.env.RECH_HOST || "127.0.0.1";
|
|
146
249
|
const canRead = (p?: string) => { try { accessSync(p!, fsConstants.R_OK); return true; } catch { return false; } };
|
|
147
250
|
const certPath = canRead(process.env.RECH_TLS_CERT) ? process.env.RECH_TLS_CERT : undefined;
|
|
@@ -155,9 +258,13 @@ export async function serve() {
|
|
|
155
258
|
}, 86_400_000);
|
|
156
259
|
}
|
|
157
260
|
const tls = certPath && keyPath ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined;
|
|
158
|
-
const startServer = () => Bun.serve({
|
|
261
|
+
const startServer = (reusePort = false) => Bun.serve({
|
|
159
262
|
hostname: listenHost,
|
|
160
263
|
port,
|
|
264
|
+
// reusePort is used only as a last-resort fallback (see the bind loop below): if an orphaned
|
|
265
|
+
// holder can't be killed, binding with SO_REUSEADDR keeps serve up (degraded, port-shared)
|
|
266
|
+
// instead of crash-looping on EADDRINUSE. The normal path binds a clean, exclusive socket.
|
|
267
|
+
reusePort,
|
|
161
268
|
tls,
|
|
162
269
|
error(err) {
|
|
163
270
|
log(`unhandled error: ${err.message}`);
|
|
@@ -181,7 +288,32 @@ export async function serve() {
|
|
|
181
288
|
if (reqUrl.pathname === "/ping") {
|
|
182
289
|
const denied = authCheck(req, key);
|
|
183
290
|
if (denied) return denied;
|
|
184
|
-
|
|
291
|
+
// Shallow ping proves only that the HTTP listener is up (it stayed up through past
|
|
292
|
+
// relay wedges). `degraded` surfaces the passive timeout streak from real traffic so
|
|
293
|
+
// clients / `rech status` can see trouble without an active probe.
|
|
294
|
+
const degraded = consecutiveTimeouts > 0;
|
|
295
|
+
if (!reqUrl.searchParams.get("deep"))
|
|
296
|
+
return Response.json({ ok: true, bind: listenHost, consecutiveTimeouts, degraded });
|
|
297
|
+
// Deep probe: exercise the relay read-only via `tab-list` (opens nothing) against the
|
|
298
|
+
// most-recently-used session — never a fresh one, which could spawn a browser window.
|
|
299
|
+
const target = [...recentSessions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
300
|
+
if (!target)
|
|
301
|
+
return Response.json({ ok: true, bind: listenHost, relay: "idle", consecutiveTimeouts, degraded });
|
|
302
|
+
const [pbin, ...pbinArgs] = splitCommand(resolvePlaywrightCli());
|
|
303
|
+
const probe = Bun.spawn([pbin, ...pbinArgs, "tab-list", `-s=${target}`], {
|
|
304
|
+
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
305
|
+
windowsHide: true,
|
|
306
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
307
|
+
});
|
|
308
|
+
const probeStatus = await Promise.race([
|
|
309
|
+
probe.exited,
|
|
310
|
+
new Promise<number>((r) => setTimeout(() => { probe.kill(); r(-1); }, 5000)),
|
|
311
|
+
]);
|
|
312
|
+
const healthy = probeStatus !== -1;
|
|
313
|
+
return Response.json({
|
|
314
|
+
ok: healthy, bind: listenHost, relay: healthy ? "healthy" : "degraded",
|
|
315
|
+
consecutiveTimeouts, degraded: degraded || !healthy,
|
|
316
|
+
});
|
|
185
317
|
}
|
|
186
318
|
if (reqUrl.pathname !== "/run") return new Response("rech server\n");
|
|
187
319
|
const denied = authCheck(req, key);
|
|
@@ -201,14 +333,19 @@ export async function serve() {
|
|
|
201
333
|
} else {
|
|
202
334
|
args = body.args;
|
|
203
335
|
const id = body.identity as
|
|
204
|
-
| { gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
|
|
336
|
+
| { key?: string; label?: string; gitUrl?: string; hostname?: string; cwd?: string; profile?: string }
|
|
205
337
|
| undefined;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
338
|
+
// New clients send {key,label} (key = worktree-root-based, decoupled from the pretty
|
|
339
|
+
// label). Fall back to the legacy {gitUrl|hostname:cwd} shape for older clients.
|
|
340
|
+
const legacy = id?.gitUrl || (id?.hostname && id?.cwd ? `${id.hostname}:${id.cwd}` : null);
|
|
341
|
+
const keyBase = id?.key || legacy;
|
|
342
|
+
const labelBase = id?.label || legacy || keyBase;
|
|
343
|
+
if (keyBase) {
|
|
344
|
+
// Hash the key (+ profile via a NUL separator that never appears in a label).
|
|
345
|
+
const hashInput = id?.profile ? `${keyBase}\u0000${id.profile}` : keyBase;
|
|
346
|
+
sessionId = createHash("sha256").update(hashInput).digest("hex").slice(0, 8);
|
|
347
|
+
clientName = labelBase || keyBase;
|
|
348
|
+
log(`session from identity: ${clientName}${id?.profile ? ` profile:${id.profile}` : ""} [${keyBase}] -> ${sessionId}`);
|
|
212
349
|
} else {
|
|
213
350
|
const clientAddr = `${req.headers.get("x-forwarded-for") || server.requestIP(req)?.address || "unknown"}`;
|
|
214
351
|
sessionId = createHash("sha256").update(clientAddr).digest("hex").slice(0, 8);
|
|
@@ -233,6 +370,10 @@ export async function serve() {
|
|
|
233
370
|
return true;
|
|
234
371
|
});
|
|
235
372
|
const namespacedSession = clientSession ? `${sessionId}-${clientSession}` : sessionId;
|
|
373
|
+
// Track --isolate sessions so the idle-TTL reaper can close them later.
|
|
374
|
+
const nowMs = Date.now();
|
|
375
|
+
if (isIsoSession(namespacedSession)) isoLastUsed.set(namespacedSession, nowMs);
|
|
376
|
+
else noteSession(namespacedSession, nowMs); // for the deep health probe to target
|
|
236
377
|
|
|
237
378
|
// daemonInstall bakes PLAYWRIGHT_CLI into the daemon env; resolvePlaywrightCli() is the
|
|
238
379
|
// fallback for a standalone `serve` (it re-runs the same env > fork > @playwright/cli > legacy chain).
|
|
@@ -259,6 +400,7 @@ export async function serve() {
|
|
|
259
400
|
stdin: "ignore",
|
|
260
401
|
stdout: "pipe",
|
|
261
402
|
stderr: "pipe",
|
|
403
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
262
404
|
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
263
405
|
});
|
|
264
406
|
const [listStatus, listOut] = await Promise.race([
|
|
@@ -341,16 +483,20 @@ export async function serve() {
|
|
|
341
483
|
stdin: "ignore",
|
|
342
484
|
stdout: "pipe",
|
|
343
485
|
stderr: "pipe",
|
|
486
|
+
windowsHide: true, // hide the CLI child's console; the user's Chrome (a GUI grandchild) stays visible
|
|
344
487
|
env: childEnv,
|
|
345
488
|
});
|
|
346
489
|
|
|
347
490
|
const TIMEOUT = 60_000;
|
|
348
|
-
|
|
349
|
-
|
|
491
|
+
let timedOut = false;
|
|
492
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
493
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
494
|
+
timer = setTimeout(() => {
|
|
495
|
+
timedOut = true;
|
|
350
496
|
proc.kill();
|
|
351
497
|
reject(new Error("timeout"));
|
|
352
|
-
}, TIMEOUT)
|
|
353
|
-
);
|
|
498
|
+
}, TIMEOUT);
|
|
499
|
+
});
|
|
354
500
|
const [status, stdout, stderr] = await Promise.race([
|
|
355
501
|
Promise.all([
|
|
356
502
|
proc.exited,
|
|
@@ -361,9 +507,37 @@ export async function serve() {
|
|
|
361
507
|
]).catch(
|
|
362
508
|
() => [1, "", `Command timed out after ${TIMEOUT / 1000}s\n`] as [number, string, string],
|
|
363
509
|
) as [number, string, string];
|
|
510
|
+
clearTimeout(timer);
|
|
364
511
|
|
|
365
512
|
log(`exit: ${status}${stdout.trim() ? ` | ${stdout.trim().slice(0, 200)}` : ""}`);
|
|
366
513
|
|
|
514
|
+
// Relay self-heal (see notes at SESSION_CLOSE_TIMEOUTS): a command that RETURNS — even
|
|
515
|
+
// non-zero — proves the relay answered, so clear the streaks; a TIMEOUT means it didn't.
|
|
516
|
+
if (timedOut) {
|
|
517
|
+
consecutiveTimeouts++;
|
|
518
|
+
const streak = (sessionTimeouts.get(namespacedSession) ?? 0) + 1;
|
|
519
|
+
sessionTimeouts.set(namespacedSession, streak);
|
|
520
|
+
log(`timeout: session=${namespacedSession} sessionStreak=${streak} globalStreak=${consecutiveTimeouts}`);
|
|
521
|
+
if (streak >= SESSION_CLOSE_TIMEOUTS) {
|
|
522
|
+
log(`session ${namespacedSession} wedged (${streak} consecutive timeouts) — closing so the next command respawns a clean cliDaemon`);
|
|
523
|
+
try {
|
|
524
|
+
Bun.spawn([bin, ...binArgs, "close", `-s=${namespacedSession}`], {
|
|
525
|
+
cwd: workDir, stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
526
|
+
windowsHide: true,
|
|
527
|
+
env: { PATH: process.env.PATH, HOME: HOME, USERPROFILE: process.env.USERPROFILE },
|
|
528
|
+
});
|
|
529
|
+
} catch {}
|
|
530
|
+
sessionTimeouts.delete(namespacedSession);
|
|
531
|
+
}
|
|
532
|
+
if (consecutiveTimeouts >= WATCHDOG_TIMEOUTS) {
|
|
533
|
+
log(`relay wedged: ${consecutiveTimeouts} consecutive timeouts, no success between — exiting for oxmgr (--restart always) to respawn clean; extension WS will reconnect (Chrome untouched)`);
|
|
534
|
+
setTimeout(() => process.exit(1), 100); // brief delay to flush this response
|
|
535
|
+
}
|
|
536
|
+
} else {
|
|
537
|
+
consecutiveTimeouts = 0;
|
|
538
|
+
sessionTimeouts.delete(namespacedSession);
|
|
539
|
+
}
|
|
540
|
+
|
|
367
541
|
// Detect files mentioned in output
|
|
368
542
|
const filePattern = /[\w./-]+\.(?:png|jpe?g|pdf|json|yml)\b/gi;
|
|
369
543
|
const mentionedFiles = [
|
|
@@ -402,17 +576,34 @@ export async function serve() {
|
|
|
402
576
|
},
|
|
403
577
|
});
|
|
404
578
|
|
|
405
|
-
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port
|
|
406
|
-
// prior serve exits
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
579
|
+
// A leaked listening-socket handle in an orphaned cliDaemon can keep the port in LISTEN after a
|
|
580
|
+
// prior serve exits: Bun.serve creates the socket inheritable and Bun.spawn sweeps it into the
|
|
581
|
+
// detached daemon grandchild via bInheritHandles, so the socket outlives its creating serve.
|
|
582
|
+
// netstat then attributes the port to the now-dead *creator*, not the live holder, so we can't
|
|
583
|
+
// map port -> killable PID — freeStalePort kills the orphan by its cliDaemon signature instead.
|
|
584
|
+
// Retry a bounded number of times: the old single retry crash-looped whenever the OS hadn't yet
|
|
585
|
+
// released the socket after the kill (pm2 then restarts serve into the same race). As an absolute
|
|
586
|
+
// last resort, bind with reusePort so a holder we genuinely can't kill degrades to "up but sharing
|
|
587
|
+
// the port" rather than a permanent EADDRINUSE crash-loop.
|
|
588
|
+
const isEaddrInUse = (e: any) => String(e?.code ?? e?.message ?? "").includes("EADDRINUSE");
|
|
589
|
+
const MAX_BIND_ATTEMPTS = 4;
|
|
590
|
+
let server: ReturnType<typeof startServer> | undefined;
|
|
591
|
+
for (let attempt = 1; attempt <= MAX_BIND_ATTEMPTS; attempt++) {
|
|
592
|
+
try {
|
|
593
|
+
server = startServer();
|
|
594
|
+
break;
|
|
595
|
+
} catch (e: any) {
|
|
596
|
+
if (!isEaddrInUse(e)) throw e;
|
|
597
|
+
if (attempt === MAX_BIND_ATTEMPTS) {
|
|
598
|
+
log(`port ${port} still held after ${attempt - 1} cleanup attempts — binding with reusePort (last resort)`);
|
|
599
|
+
server = startServer(true);
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
log(`port ${port} in use — clearing stale daemon holders and retrying (attempt ${attempt}/${MAX_BIND_ATTEMPTS - 1})`);
|
|
603
|
+
await freeStalePort(port);
|
|
604
|
+
}
|
|
415
605
|
}
|
|
606
|
+
if (!server) throw new Error(`failed to bind port ${port}`);
|
|
416
607
|
|
|
417
608
|
log(`serving on ${tls ? "https" : "http"}://${server.hostname}:${server.port}`);
|
|
418
609
|
log(`Connection URL set (use .env.local to view)`);
|