cdp-toolkit 2.1.0 → 2.3.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The detached BiDi mux DAEMON: a tiny process whose only job is to OWN Firefox's single
3
+ * WebDriver-BiDi session and serve it to every cdp-toolkit process through a loopback mux (./mux.ts).
4
+ *
5
+ * WHY IT IS A SEPARATE PROCESS. Firefox 153.0.3 regenerates EVERY top-level browsing-context id
6
+ * whenever its one BiDi session is re-created — measured: a clean session.end + session.new keeps
7
+ * the tab (same url) but browsingContext.getTree reports a new id; a SIGKILLed holder is worse, as
8
+ * Firefox never reaps the dead session at all and only a Marionette WebDriver:DeleteSession frees
9
+ * it. So while the session lived inside a client process, the moment that client exited — even
10
+ * cleanly, e.g. the first agent session finishing while a second is mid-task — every other client's
11
+ * cached target ids, tab leases and origin records went stale. Hosting the session here means no
12
+ * client's lifetime is the session's lifetime.
13
+ *
14
+ * WHY IT STILL EXITS. A daemon nobody asked for is a leak, so it exits as soon as it is unused:
15
+ * CDP_FIREFOX_MUX_IDLE_MS (default 15s) with zero clients, counted from startup too, so a spawner
16
+ * that dies before connecting leaves nothing behind. It also exits when Firefox goes away or on a
17
+ * signal, always through the same dispose (mux close -> session.end -> slot release).
18
+ *
19
+ * Argv: <endpoint> <spawnerPid>. Env read exactly as the driver reads it (CDP_ARTIFACT_DIR,
20
+ * CDP_FIREFOX_MARIONETTE_PORT, CDP_FIREFOX_SESSION_WAIT_MS), plus CDP_FIREFOX_MUX_IDLE_MS.
21
+ * Spawned detached with stdio:"ignore", so it has NO console: every diagnostic goes to
22
+ * <leaseDir()>/ff-mux-<safeEndpoint>.log, best-effort, never throwing.
23
+ */
24
+ import { appendFileSync, mkdirSync } from "node:fs";
25
+ import { dirname } from "node:path";
26
+ import { muxDaemonLogFile } from "./session-lease.ts";
27
+
28
+ const DEFAULT_IDLE_MS = 15_000;
29
+ const CLIENT_POLL_MS = 500;
30
+ const DISPOSE_TIMEOUT_MS = 10_000;
31
+
32
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
33
+
34
+ const endpoint = process.argv[2];
35
+ const spawnerPid = process.argv[3] ?? "?";
36
+
37
+ function idleMs(): number {
38
+ const raw = Number(process.env.CDP_FIREFOX_MUX_IDLE_MS);
39
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_IDLE_MS;
40
+ }
41
+
42
+ /** Append one diagnostic line. Never throws: a daemon must not die because a log write failed. */
43
+ function log(line: string): void {
44
+ if (!endpoint) return;
45
+ try {
46
+ const file = muxDaemonLogFile(endpoint);
47
+ mkdirSync(dirname(file), { recursive: true });
48
+ appendFileSync(file, `${new Date().toISOString()} pid=${process.pid} ${line}\n`);
49
+ } catch {
50
+ /* best effort */
51
+ }
52
+ }
53
+
54
+ function fail(reason: string): never {
55
+ log(`EXIT(1) ${reason}`);
56
+ process.exit(1);
57
+ }
58
+
59
+ async function main(): Promise<void> {
60
+ if (!endpoint) fail("no endpoint argument");
61
+ // Must be set BEFORE the driver module is imported so its holder path is the one that runs;
62
+ // this is also the flag hostFirefoxMux asserts on.
63
+ process.env.CDP_FIREFOX_MUX = "host";
64
+ log(`start endpoint=${endpoint} spawner=${spawnerPid} idleMs=${idleMs()}`);
65
+
66
+ const { hostFirefoxMux } = await import("./driver.ts");
67
+ let hosted: Awaited<ReturnType<typeof hostFirefoxMux>>;
68
+ try {
69
+ hosted = await hostFirefoxMux(endpoint, { label: `mux-daemon(spawned by pid ${spawnerPid})` });
70
+ } catch (e) {
71
+ // Busy slot, a racing daemon that won (arrives as a join), Marionette unavailable, dial error.
72
+ // Every one of these is "this daemon is not the holder", and the spawner's fallback path
73
+ // reproduces the actionable error in-process. Nothing was written that is not ours to write.
74
+ fail(`could not host: ${e instanceof Error ? e.message : String(e)}`);
75
+ }
76
+ log(`advertised mux=${hosted.mux.endpoint}`);
77
+
78
+ let shuttingDown = false;
79
+ const shutdown = async (reason: string): Promise<void> => {
80
+ if (shuttingDown) return;
81
+ shuttingDown = true;
82
+ clearInterval(timer);
83
+ log(`shutdown begin: ${reason}`);
84
+ // BOUNDED. dispose() closes the mux's listening socket, and node's server.close() only calls
85
+ // back once every accepted connection is gone — a client that connects while we are closing can
86
+ // therefore hold it open indefinitely. A daemon that cannot die is worse than one that dies
87
+ // without a clean session.end (the slot record it leaves behind carries a DEAD pid, which the
88
+ // next process steals and force-clears over Marionette, the existing #4 recovery path). So the
89
+ // teardown gets a deadline and we exit either way.
90
+ await Promise.race([hosted.dispose().catch(() => undefined), sleep(DISPOSE_TIMEOUT_MS)]);
91
+ log(`EXIT(0) ${reason}`);
92
+ process.exit(0);
93
+ };
94
+
95
+ // Idle exit. The clock starts NOW, not at the first client, so a spawner that never connects
96
+ // (crashed, or fell back) cannot leave a daemon behind.
97
+ let idleSince: number | undefined = Date.now();
98
+ const timer = setInterval(() => {
99
+ if (shuttingDown) return;
100
+ if (!hosted.conn.isOpen) {
101
+ void shutdown("upstream Firefox connection closed");
102
+ return;
103
+ }
104
+ if (hosted.mux.clients() > 0) {
105
+ idleSince = undefined;
106
+ return;
107
+ }
108
+ if (idleSince === undefined) idleSince = Date.now();
109
+ if (Date.now() - idleSince >= idleMs()) void shutdown(`idle for ${idleMs()}ms with no clients`);
110
+ }, Math.max(50, Math.min(CLIENT_POLL_MS, Math.floor(idleMs() / 4))));
111
+
112
+ for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) {
113
+ process.on(sig, () => void shutdown(`signal ${sig}`));
114
+ }
115
+ // A dispose on the way out of an unexpected throw, so we never strand Firefox's session.
116
+ process.on("uncaughtException", (e) => {
117
+ log(`uncaught: ${e instanceof Error ? e.message : String(e)}`);
118
+ void shutdown("uncaught exception");
119
+ });
120
+ }
121
+
122
+ void main().catch((e: unknown) => fail(`startup threw: ${e instanceof Error ? e.message : String(e)}`));