cdp-toolkit 2.0.0 → 2.2.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/CHANGELOG.md +53 -0
- package/README.md +76 -20
- package/dist/bidi/mux-daemon.js +3773 -0
- package/dist/cli.js +6457 -5690
- package/dist/index.js +275 -210
- package/dist/mcp.js +8185 -7420
- package/package.json +2 -1
- package/src/bidi/driver.ts +329 -30
- package/src/bidi/mux-daemon.ts +122 -0
- package/src/bidi/mux.ts +610 -0
- package/src/bidi/session-lease.ts +95 -3
- package/src/manifest.ts +1 -0
- package/src/mcp.ts +147 -127
- package/src/toolGroups.ts +32 -0
- package/src/version.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"firefox:smoke": "bun run ./test/firefox-smoke.ts",
|
|
81
81
|
"firefox:attach:smoke": "bun run ./test/firefox-attach-smoke.ts",
|
|
82
82
|
"firefox:coord:smoke": "bun run ./test/firefox-session-coord-smoke.ts",
|
|
83
|
+
"firefox:multi:smoke": "bun run ./test/firefox-multi-agent-smoke.ts",
|
|
83
84
|
"lease:smoke": "bun run ./test/lease-smoke.ts",
|
|
84
85
|
"staleness:smoke": "bun run ./test/staleness-smoke.ts",
|
|
85
86
|
"input:smoke": "bun run ./test/input-smoke.ts",
|
package/src/bidi/driver.ts
CHANGED
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
* function through script.callFunction and PARSE its text back into SnapshotNode[], which is
|
|
30
30
|
* genuinely new integration work, not a reimplementation of the walker itself.
|
|
31
31
|
*/
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import { existsSync } from "node:fs";
|
|
32
35
|
import type { TargetSelector } from "../types.ts";
|
|
33
36
|
import {
|
|
34
37
|
UID_STAMP_ATTR, isDriverError, interpolatePoints, clearViewportRecord, readViewportRecord, renderRestoreFailureNote, writeViewportRecord,
|
|
@@ -40,15 +43,16 @@ import {
|
|
|
40
43
|
type ScreenshotOptions, type ScreenshotResult, type ScrollOptions, type SnapshotNode, type UidStability,
|
|
41
44
|
type BandConsumer, type CaptureDelivery,
|
|
42
45
|
} from "../driver.ts";
|
|
43
|
-
import { assertLeaseOk, defaultLabel } from "../leases.ts";
|
|
46
|
+
import { assertLeaseOk, defaultLabel, isPidAlive } from "../leases.ts";
|
|
44
47
|
import { resolveLiveLabel } from "../origins.ts";
|
|
45
48
|
import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
|
|
46
49
|
import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
|
|
47
50
|
import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
|
|
48
51
|
import {
|
|
49
|
-
acquireSessionSlot, releaseSessionSlot, SessionSlotBusyError,
|
|
50
|
-
type AcquireResult, type SessionSlotHandle,
|
|
52
|
+
acquireSessionSlot, advertiseMux, muxDaemonLogFile, readSessionSlot, releaseSessionSlot, sessionWaitMs, SessionSlotBusyError,
|
|
53
|
+
type AcquireResult, type SessionSlotHandle, type SessionSlotRecord,
|
|
51
54
|
} from "./session-lease.ts";
|
|
55
|
+
import { startBidiMux, type BidiMux } from "./mux.ts";
|
|
52
56
|
import { defaultMarionettePort, forceClearOrphanSession } from "./marionette.ts";
|
|
53
57
|
import { takeStampedSnapshot } from "./snapshot.ts";
|
|
54
58
|
import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
|
|
@@ -346,12 +350,150 @@ const connections = new Map<string, BidiConnection>();
|
|
|
346
350
|
// contending with our own pid. Populated in getConnection's create body; cleared on dispose() and
|
|
347
351
|
// on a terminal dial failure that never established a session.
|
|
348
352
|
const slotHandles = new Map<string, SessionSlotHandle>();
|
|
353
|
+
// The BiDi multiplexer WE host, keyed by the FIREFOX endpoint whose real session it fronts (see
|
|
354
|
+
// ./mux.ts). Populated by getConnection's holder path right after the real connection is cached;
|
|
355
|
+
// closed first thing in dispose(), so joiners see their sockets drop while we still hold the slot.
|
|
356
|
+
// Absent when this process is not a holder, when startBidiMux failed (the holder just runs alone),
|
|
357
|
+
// or under CDP_FIREFOX_MUX=off.
|
|
358
|
+
const muxes = new Map<string, BidiMux>();
|
|
359
|
+
// Firefox endpoints this process reached THROUGH another process's mux rather than by owning the
|
|
360
|
+
// session. Keyed by the FIREFOX endpoint (the connections map's key) even though the socket really
|
|
361
|
+
// points at the holder's loopback mux, so page()/the beacon map/dispose all stay keyed on one
|
|
362
|
+
// identity. Membership means exactly two things: we hold NO session slot for this endpoint (never
|
|
363
|
+
// release one), and a dead socket here must NOT take the `owned` re-dial branch — it must re-run
|
|
364
|
+
// acquire, which either joins the new holder or wins the slot outright.
|
|
365
|
+
const joined = new Set<string>();
|
|
349
366
|
// In-flight create dedup, keyed by endpoint. Two page() acquisitions can both cache-miss and call
|
|
350
367
|
// getConnection concurrently; without this both would run the acquire+dial body, and the second
|
|
351
368
|
// would either contend for the slot we just took (same pid → wait then SessionSlotBusyError against
|
|
352
369
|
// ourselves) or open a second BiDi session. The first caller stores its create-promise here and
|
|
353
370
|
// every other awaits it; cleared in a finally once the create settles.
|
|
354
371
|
const connecting = new Map<string, Promise<BidiConnection>>();
|
|
372
|
+
// Per-endpoint SESSION EPOCH: how many times THIS process has established a connection to this
|
|
373
|
+
// endpoint (as a holder, or through a mux). Firefox regenerates every browsing-context id whenever
|
|
374
|
+
// its single BiDi session is re-created, so any id this process handed out before an epoch bump is
|
|
375
|
+
// dead — measured on Firefox 153.0.3: after session.end + session.new the tab survives at the same
|
|
376
|
+
// url but browsingContext.getTree reports a NEW context id. The epoch is what lets a
|
|
377
|
+
// "no such frame"/"no context matching" failure say WHY instead of reading like a typo. We track it
|
|
378
|
+
// per ENDPOINT, not per id (the brief's permitted simplification): the hint is appended whenever
|
|
379
|
+
// epoch > 1, which can over-attach the hint to an id that was simply closed, and never under-attach.
|
|
380
|
+
const sessionEpochs = new Map<string, number>();
|
|
381
|
+
// Reverse lookup so resolveContext (which only has a BidiConnection) can find its endpoint's epoch.
|
|
382
|
+
const connEndpoints = new WeakMap<BidiConnection, string>();
|
|
383
|
+
|
|
384
|
+
function bumpEpoch(endpoint: string, conn: BidiConnection): void {
|
|
385
|
+
sessionEpochs.set(endpoint, (sessionEpochs.get(endpoint) ?? 0) + 1);
|
|
386
|
+
connEndpoints.set(conn, endpoint);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Appended to a no-such-target error once this process has re-connected at least once. */
|
|
390
|
+
const STALE_ID_HINT =
|
|
391
|
+
" — the Firefox BiDi session was re-established after its previous holder went away, and Firefox " +
|
|
392
|
+
"assigns new context ids per session — re-resolve the tab with list_pages (url:/title: selectors) " +
|
|
393
|
+
"and claim it again";
|
|
394
|
+
|
|
395
|
+
function withStaleIdHint(conn: BidiConnection, err: unknown): unknown {
|
|
396
|
+
const endpoint = connEndpoints.get(conn);
|
|
397
|
+
if (endpoint === undefined) return err;
|
|
398
|
+
if ((sessionEpochs.get(endpoint) ?? 0) <= 1) return err;
|
|
399
|
+
const e = err as DriverError;
|
|
400
|
+
if (!isDriverError(e) || e.code !== "no-such-target") return err;
|
|
401
|
+
if (e.message.includes("re-established")) return err;
|
|
402
|
+
e.message += STALE_ID_HINT;
|
|
403
|
+
return e;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Which mux strategy this connection uses. `off` is the pre-mux behavior (no mux hosted,
|
|
408
|
+
* an advertised one ignored). `host` hosts the mux IN THIS PROCESS — correct for LAUNCH mode
|
|
409
|
+
* (a private Firefox this toolkit owns, which dies with us anyway) and an opt-in for anyone who
|
|
410
|
+
* does not want a stray process. Unset/`daemon` in ATTACH mode is the DEFAULT and the whole point
|
|
411
|
+
* of the daemon: the real Firefox session must not live in a client process, because when that
|
|
412
|
+
* client exits — even cleanly — Firefox regenerates every context id and every other client's
|
|
413
|
+
* cached ids, tab leases and origin records go stale.
|
|
414
|
+
*/
|
|
415
|
+
type MuxMode = "off" | "host" | "daemon";
|
|
416
|
+
function muxMode(attached: boolean): MuxMode {
|
|
417
|
+
const raw = process.env.CDP_FIREFOX_MUX;
|
|
418
|
+
if (raw === "off") return "off";
|
|
419
|
+
if (raw === "host") return "host";
|
|
420
|
+
if (raw === "daemon") return "daemon";
|
|
421
|
+
return attached ? "daemon" : "host";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Absolute path of the daemon entry point, or undefined if we cannot find one to spawn.
|
|
425
|
+
* Order: explicit env override (tests), the source tree (`bun run src/…`), then the built
|
|
426
|
+
* bundle. `import.meta.url` inside dist is dist/{mcp,cli,index}.js, so the sibling is
|
|
427
|
+
* dist/bidi/mux-daemon.js (scripts/build.ts roots entrypoints at src/). */
|
|
428
|
+
function resolveDaemonScript(): string | undefined {
|
|
429
|
+
const override = process.env.CDP_FIREFOX_MUX_DAEMON;
|
|
430
|
+
if (override) return override;
|
|
431
|
+
for (const rel of ["./mux-daemon.ts", "./mux-daemon.js", "./bidi/mux-daemon.js", "../bidi/mux-daemon.js"]) {
|
|
432
|
+
let p: string;
|
|
433
|
+
try {
|
|
434
|
+
p = fileURLToPath(new URL(rel, import.meta.url));
|
|
435
|
+
} catch {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (existsSync(p)) return p;
|
|
439
|
+
}
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
444
|
+
|
|
445
|
+
// The label stamped on a slot record WE take. defaultLabel() is "pid-<pid>", which is right for an
|
|
446
|
+
// MCP server but useless for the daemon, whose record a human reads when they wonder what process
|
|
447
|
+
// is holding their browser. hostFirefoxMux sets it to "mux-daemon(spawned by pid N)".
|
|
448
|
+
let slotLabelOverride: string | undefined;
|
|
449
|
+
|
|
450
|
+
/** A LIVE holder advertising a mux, or undefined. */
|
|
451
|
+
async function liveAdvertisedHolder(endpoint: string): Promise<{ endpoint: string; holder: SessionSlotRecord } | undefined> {
|
|
452
|
+
const rec = await readSessionSlot(endpoint).catch(() => undefined);
|
|
453
|
+
if (!rec || !rec.muxEndpoint || !isPidAlive(rec.pid)) return undefined;
|
|
454
|
+
return { endpoint: rec.muxEndpoint, holder: rec };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* DAEMON MODE: get a mux endpoint to join WITHOUT ever becoming the holder in this process.
|
|
459
|
+
* Returns the advertised endpoint, or undefined meaning "no daemon could be brought up — fall
|
|
460
|
+
* back to the in-process host path", which is what turns a genuinely unavailable browser back
|
|
461
|
+
* into the existing actionable busy/Marionette errors instead of a mystery timeout.
|
|
462
|
+
* Throws only for the deadline case, which is a real failure with a real diagnostic (the log).
|
|
463
|
+
*/
|
|
464
|
+
async function ensureMuxDaemon(endpoint: string): Promise<{ endpoint: string; holder: SessionSlotRecord } | undefined> {
|
|
465
|
+
const already = await liveAdvertisedHolder(endpoint);
|
|
466
|
+
if (already) return already;
|
|
467
|
+
|
|
468
|
+
const script = resolveDaemonScript();
|
|
469
|
+
if (!script) return undefined; // no daemon to spawn: host it ourselves
|
|
470
|
+
|
|
471
|
+
let exited = false;
|
|
472
|
+
const child = spawn(process.execPath, [script, endpoint, String(process.pid)], {
|
|
473
|
+
detached: true,
|
|
474
|
+
stdio: "ignore",
|
|
475
|
+
env: { ...process.env, CDP_FIREFOX_MUX: "host" },
|
|
476
|
+
});
|
|
477
|
+
child.once("error", () => { exited = true; });
|
|
478
|
+
// Fires even on an unref'ed child while WE are alive, which is exactly the fast negative signal
|
|
479
|
+
// we need: a daemon that refused (busy slot, Marionette missing, dial error) exits non-zero at
|
|
480
|
+
// once, and we fall back rather than waiting out the deadline.
|
|
481
|
+
child.once("exit", () => { exited = true; });
|
|
482
|
+
child.unref();
|
|
483
|
+
|
|
484
|
+
const deadline = Date.now() + sessionWaitMs() + 5_000;
|
|
485
|
+
while (Date.now() < deadline) {
|
|
486
|
+
const hit = await liveAdvertisedHolder(endpoint);
|
|
487
|
+
if (hit) return hit;
|
|
488
|
+
if (exited) return undefined; // daemon gave up: host in-process and surface ITS error
|
|
489
|
+
await sleep(50);
|
|
490
|
+
}
|
|
491
|
+
throw driverError("disconnected",
|
|
492
|
+
`Firefox's single WebDriver BiDi session on ${endpoint} could not be brought up: a mux daemon was ` +
|
|
493
|
+
`spawned (${script}) but never advertised a multiplexer within ${sessionWaitMs() + 5_000}ms. ` +
|
|
494
|
+
`Its diagnostics are in ${muxDaemonLogFile(endpoint)}. Retry, or set CDP_FIREFOX_MUX=host to run ` +
|
|
495
|
+
`the session in this process instead.`);
|
|
496
|
+
}
|
|
355
497
|
|
|
356
498
|
/**
|
|
357
499
|
* Release a slot claim we took in THIS getConnection call after a dial that never established a
|
|
@@ -396,7 +538,20 @@ function orphanNotClearedError(endpoint: string): DriverError {
|
|
|
396
538
|
// WebDriver:DeleteSession (which shares Firefox's one session slot) force-clears the orphan WITHOUT
|
|
397
539
|
// killing the user's browser, and we retry. Marionette recovery is best-effort and degrades to a
|
|
398
540
|
// clear, actionable error when Firefox has no --marionette port (the launch-mode norm).
|
|
399
|
-
|
|
541
|
+
//
|
|
542
|
+
// AND THE THIRD OUTCOME, THE MUX JOIN. "Wait and retry" was the honest answer while one process
|
|
543
|
+
// could only ever hand the session to the next one. It no longer is: the holder now hosts a
|
|
544
|
+
// loopback BiDi multiplexer over its one real session (./mux.ts) and advertises it in the slot
|
|
545
|
+
// record, so when acquire reports a LIVE holder WITH a muxEndpoint we do not wait at all — we dial
|
|
546
|
+
// the holder's mux with the same connectBidiSessionUrl and the same dialOpts we would use against
|
|
547
|
+
// Firefox, and cache the resulting socket under the FIREFOX endpoint key. Downstream, a joined
|
|
548
|
+
// connection is indistinguishable from a real one; the only bookkeeping is the `joined` set, which
|
|
549
|
+
// records that we hold NO slot (so dispose releases nothing and ends nothing) and that a dead
|
|
550
|
+
// socket here must re-run acquire rather than take the `owned` re-dial shortcut. A live holder with
|
|
551
|
+
// NO mux is still plain busy, exactly as before. On the holder side, the only addition is that
|
|
552
|
+
// after the real connection is cached we start a mux and advertise it — best-effort, because a
|
|
553
|
+
// holder that fails to start one is simply a holder that serves nobody but itself.
|
|
554
|
+
async function getConnection(endpoint: string, timeoutMs?: number, attached = false): Promise<BidiConnection> {
|
|
400
555
|
const existing = connections.get(endpoint);
|
|
401
556
|
if (existing && existing.isOpen) { existing.retain(); return existing; }
|
|
402
557
|
// A cached connection whose socket has died (ws.onclose fired) can only reject every
|
|
@@ -421,20 +576,63 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
|
|
|
421
576
|
|
|
422
577
|
const create = (async (): Promise<BidiConnection> => {
|
|
423
578
|
const mport = defaultMarionettePort();
|
|
579
|
+
// Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
|
|
580
|
+
// gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
|
|
581
|
+
// reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
|
|
582
|
+
// next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
|
|
583
|
+
// and answer a live prompt at all. Declared before the acquire so the JOIN path can dial the
|
|
584
|
+
// holder's mux with byte-identical options — a joined connection must be indistinguishable.
|
|
585
|
+
const dialOpts = {
|
|
586
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
587
|
+
capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" as const } } },
|
|
588
|
+
};
|
|
589
|
+
|
|
424
590
|
// Do we ALREADY hold this endpoint's slot? (e.g. our previous socket died without dispose, so
|
|
425
591
|
// the eviction above kept the claim.) If so, re-dial WITHOUT re-acquiring: acquireSessionSlot
|
|
426
592
|
// against our own live pid would just poll to its wait ceiling and then throw
|
|
427
593
|
// SessionSlotBusyError against ourselves. We already own Firefox's slot; only the socket needs
|
|
428
594
|
// re-establishing (and if Firefox stranded our old session, the re-dial's retry path clears it).
|
|
429
|
-
|
|
430
|
-
|
|
595
|
+
let owned = slotHandles.has(endpoint);
|
|
596
|
+
// ...UNLESS the dead socket we just evicted was a JOINED one. A joiner owns no slot and no
|
|
597
|
+
// session, so the `owned` shortcut would re-dial Firefox directly against a session someone
|
|
598
|
+
// else holds. Drop the join marker and run the full acquire, which either joins whoever holds
|
|
599
|
+
// the slot NOW (the old holder recovered, or a new one took over) or wins the slot outright.
|
|
600
|
+
if (joined.has(endpoint)) { joined.delete(endpoint); owned = false; }
|
|
601
|
+
|
|
602
|
+
// DAEMON MODE (the default in ATTACH mode). Never become the holder in-process: either join a
|
|
603
|
+
// daemon that already advertises a mux, or spawn one and join it as soon as it does. The real
|
|
604
|
+
// session then outlives every client, which is the entire point — see ensureMuxDaemon.
|
|
605
|
+
// A daemon that refuses returns undefined and we fall through to the host path below, so an
|
|
606
|
+
// unavailable browser still produces the existing actionable busy/Marionette errors.
|
|
607
|
+
if (!owned && muxMode(attached) === "daemon") {
|
|
608
|
+
const target = await ensureMuxDaemon(endpoint);
|
|
609
|
+
if (target) {
|
|
610
|
+
try {
|
|
611
|
+
const joinedConn = await connectBidiSessionUrl(target.endpoint, dialOpts); // already retained once
|
|
612
|
+
connections.set(endpoint, joinedConn);
|
|
613
|
+
joined.add(endpoint);
|
|
614
|
+
bumpEpoch(endpoint, joinedConn);
|
|
615
|
+
return joinedConn;
|
|
616
|
+
} catch (e) {
|
|
617
|
+
// The daemon died between advertising and our dial. Fall through to the ordinary
|
|
618
|
+
// acquire loop, which steals the orphan record and re-runs the whole flow.
|
|
619
|
+
console.error(`[cdp-toolkit] firefox mux daemon at ${target.endpoint} could not be dialed: ${e instanceof Error ? e.message : String(e)}`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The acquire runs in a loop only so the JOIN path can retry itself exactly ONCE (below);
|
|
625
|
+
// every other path breaks out on its first pass.
|
|
626
|
+
for (let attempt = 0; ; attempt++) {
|
|
627
|
+
if (owned) break;
|
|
431
628
|
// Cross-process coordination (session-lease.ts): take the endpoint's single BiDi session slot,
|
|
432
|
-
// classifying any current holder. A LIVE holder
|
|
433
|
-
//
|
|
434
|
-
// staleHolder so we can force-clear Firefox's
|
|
629
|
+
// classifying any current holder. A LIVE holder that ADVERTISES A MUX is JOINED rather than
|
|
630
|
+
// waited on; a LIVE holder without one still throws SessionSlotBusyError exactly as before; a
|
|
631
|
+
// DEAD holder's orphan is stolen and surfaced as staleHolder so we can force-clear Firefox's
|
|
632
|
+
// stranded session before we call session.new.
|
|
435
633
|
let acq: AcquireResult;
|
|
436
634
|
try {
|
|
437
|
-
acq = await acquireSessionSlot(endpoint, { label: defaultLabel(), marionettePort: mport });
|
|
635
|
+
acq = await acquireSessionSlot(endpoint, { label: slotLabelOverride ?? defaultLabel(), marionettePort: mport });
|
|
438
636
|
} catch (e) {
|
|
439
637
|
if (e instanceof SessionSlotBusyError) {
|
|
440
638
|
// Issue #5: a distinguishable LIVE holder, NOT a wedge. Map to an actionable disconnected
|
|
@@ -448,7 +646,35 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
|
|
|
448
646
|
}
|
|
449
647
|
throw e;
|
|
450
648
|
}
|
|
451
|
-
|
|
649
|
+
|
|
650
|
+
if (acq.joinMux) {
|
|
651
|
+
// JOIN: the live holder fronts its one real session with a loopback BiDi mux, so we dial
|
|
652
|
+
// THAT instead of Firefox. We take no slot and open no real session — but we cache the
|
|
653
|
+
// socket under the FIREFOX endpoint key, so page(), the beacon map and dispose() are all
|
|
654
|
+
// keyed on one identity and neither knows nor cares that the bytes go via the holder.
|
|
655
|
+
const { endpoint: muxEndpoint, holder } = acq.joinMux;
|
|
656
|
+
try {
|
|
657
|
+
const joinedConn = await connectBidiSessionUrl(muxEndpoint, dialOpts); // already retained once
|
|
658
|
+
connections.set(endpoint, joinedConn);
|
|
659
|
+
joined.add(endpoint);
|
|
660
|
+
bumpEpoch(endpoint, joinedConn);
|
|
661
|
+
return joinedConn;
|
|
662
|
+
} catch (e) {
|
|
663
|
+
// A holder can die between advertising and our dial, leaving a stale muxEndpoint in a
|
|
664
|
+
// record whose pid is about to read dead. Re-run the WHOLE acquire once: we will either
|
|
665
|
+
// steal that orphan and become the holder ourselves, or join whoever took over.
|
|
666
|
+
if (attempt === 0) continue;
|
|
667
|
+
const who = `'${holder.label}' (pid ${holder.pid})`;
|
|
668
|
+
throw driverError("disconnected",
|
|
669
|
+
`Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}, ` +
|
|
670
|
+
`which advertises a BiDi multiplexer at ${muxEndpoint}, but that multiplexer could not be ` +
|
|
671
|
+
`dialed (${e instanceof Error ? e.message : String(e)}). The holder is most likely shutting ` +
|
|
672
|
+
`down; retry in a moment, or point this server at a different endpoint/browser.`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// We took the slot ourselves: the ordinary HOLDER path, unchanged.
|
|
677
|
+
if (acq.handle) slotHandles.set(endpoint, acq.handle);
|
|
452
678
|
if (acq.staleHolder) {
|
|
453
679
|
// Issue #4 auto-recovery: we stole a DEAD holder's lease, but Firefox itself may still hold
|
|
454
680
|
// that holder's orphaned BiDi session. Force-clear it over Marionette BEFORE dialing, or
|
|
@@ -457,17 +683,9 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
|
|
|
457
683
|
// still dial, and the re-dial retry path below surfaces a clear error if the orphan persists.
|
|
458
684
|
await forceClearOrphanSession(acq.staleHolder.marionettePort ?? mport);
|
|
459
685
|
}
|
|
686
|
+
break;
|
|
460
687
|
}
|
|
461
688
|
|
|
462
|
-
// Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
|
|
463
|
-
// gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
|
|
464
|
-
// reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
|
|
465
|
-
// next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
|
|
466
|
-
// and answer a live prompt at all.
|
|
467
|
-
const dialOpts = {
|
|
468
|
-
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
469
|
-
capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" as const } } },
|
|
470
|
-
};
|
|
471
689
|
let conn: BidiConnection;
|
|
472
690
|
try {
|
|
473
691
|
conn = await connectBidiSessionUrl(endpoint, dialOpts); // already retained once
|
|
@@ -498,6 +716,22 @@ async function getConnection(endpoint: string, timeoutMs?: number): Promise<Bidi
|
|
|
498
716
|
}
|
|
499
717
|
}
|
|
500
718
|
connections.set(endpoint, conn);
|
|
719
|
+
bumpEpoch(endpoint, conn);
|
|
720
|
+
// We are the HOLDER. Front our one real session with a loopback BiDi mux and advertise it in
|
|
721
|
+
// the slot record, so any other process that wants this Firefox JOINS us instead of being told
|
|
722
|
+
// the slot is busy. Strictly additive and strictly best-effort: any failure here leaves a
|
|
723
|
+
// perfectly good holder that simply serves nobody but itself (exactly pre-mux behavior), so it
|
|
724
|
+
// is logged to stderr and swallowed rather than failing the connection the caller asked for.
|
|
725
|
+
if (muxMode(attached) !== "off") {
|
|
726
|
+
try {
|
|
727
|
+
const mux = await startBidiMux(conn);
|
|
728
|
+
muxes.set(endpoint, mux);
|
|
729
|
+
const h = slotHandles.get(endpoint);
|
|
730
|
+
if (h) await advertiseMux(h, mux.endpoint);
|
|
731
|
+
} catch (e) {
|
|
732
|
+
console.error(`[cdp-toolkit] firefox mux not started: ${e instanceof Error ? e.message : String(e)}`);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
501
735
|
return conn;
|
|
502
736
|
})();
|
|
503
737
|
|
|
@@ -553,7 +787,10 @@ async function resolveContext(
|
|
|
553
787
|
): Promise<BrowsingContextInfo> {
|
|
554
788
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
|
|
555
789
|
if (!contexts.length) throw driverError("no-such-target", "no browsing contexts available");
|
|
556
|
-
|
|
790
|
+
// A no-such-target here is the exact symptom of Firefox's per-session context-id regeneration
|
|
791
|
+
// once this process has re-connected at least once, so the error explains it rather than reading
|
|
792
|
+
// like a typo. The CODE is left untouched (still no-such-target), so caller handling is unchanged.
|
|
793
|
+
const hit = await pickContext(conn, contexts, selector).catch((e) => { throw withStaleIdHint(conn, e); });
|
|
557
794
|
// liveIds deliberately not passed: hit is by construction a member of contexts, so target-gone
|
|
558
795
|
// could never fire for the context just resolved (see leases.ts's assertLeaseOk callers).
|
|
559
796
|
await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
|
|
@@ -1381,10 +1618,10 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1381
1618
|
readonly snapshotFidelity = "dom-heuristic" as const;
|
|
1382
1619
|
/** `endpoint` is a BiDi ws URL (see getConnection): derived from a port for a launched
|
|
1383
1620
|
* Firefox, user-supplied for an attached one. It is this driver's whole identity. */
|
|
1384
|
-
constructor(private readonly endpoint: string, private readonly timeoutMs?: number) {}
|
|
1621
|
+
constructor(private readonly endpoint: string, private readonly timeoutMs?: number, private readonly attached = false) {}
|
|
1385
1622
|
|
|
1386
1623
|
async listPages(): Promise<PageInfo[]> {
|
|
1387
|
-
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1624
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
1388
1625
|
try {
|
|
1389
1626
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
|
|
1390
1627
|
return Promise.all(contexts.map((c) => pageInfoOf(conn, c)));
|
|
@@ -1393,7 +1630,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1393
1630
|
}
|
|
1394
1631
|
}
|
|
1395
1632
|
async newPage(url?: string): Promise<PageInfo> {
|
|
1396
|
-
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1633
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
1397
1634
|
try {
|
|
1398
1635
|
const { context } = await conn.send("browsingContext.create", { type: "tab" }).catch((e) => { throw mapBidiError(e); });
|
|
1399
1636
|
// Established fact: navigating the default about:home context to a data: URL is rejected,
|
|
@@ -1405,7 +1642,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1405
1642
|
}
|
|
1406
1643
|
}
|
|
1407
1644
|
async closePage(id: string): Promise<{ success: boolean }> {
|
|
1408
|
-
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1645
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
1409
1646
|
try {
|
|
1410
1647
|
await conn.send("browsingContext.close", { context: id }).catch((e) => { throw mapBidiError(e); });
|
|
1411
1648
|
return { success: true };
|
|
@@ -1414,7 +1651,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1414
1651
|
}
|
|
1415
1652
|
}
|
|
1416
1653
|
async activatePage(id: string): Promise<PageInfo> {
|
|
1417
|
-
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1654
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
1418
1655
|
try {
|
|
1419
1656
|
await conn.send("browsingContext.activate", { context: id }).catch((e) => { throw mapBidiError(e); });
|
|
1420
1657
|
const ctx = await resolveContext(conn, id);
|
|
@@ -1424,7 +1661,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1424
1661
|
}
|
|
1425
1662
|
}
|
|
1426
1663
|
async page(selector: TargetSelector): Promise<PageDriver> {
|
|
1427
|
-
const conn = await getConnection(this.endpoint, this.timeoutMs);
|
|
1664
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached);
|
|
1428
1665
|
let ctx: BrowsingContextInfo;
|
|
1429
1666
|
try {
|
|
1430
1667
|
ctx = await resolveContext(conn, selector);
|
|
@@ -1452,7 +1689,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1452
1689
|
* an annotation that must never fail real work.
|
|
1453
1690
|
*/
|
|
1454
1691
|
async installActivityBeacon(contextId: string): Promise<boolean> {
|
|
1455
|
-
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
|
|
1692
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached).catch(() => undefined);
|
|
1456
1693
|
if (!conn) return false;
|
|
1457
1694
|
try {
|
|
1458
1695
|
const key = `${this.endpoint}:${contextId}`;
|
|
@@ -1479,7 +1716,7 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1479
1716
|
/** Read the beacon timestamp for a context, or null when it has none. Gate-free;
|
|
1480
1717
|
* see the declaration in ../driver.ts for why that is required here. */
|
|
1481
1718
|
async readActivityBeacon(contextId: string): Promise<number | null> {
|
|
1482
|
-
const conn = await getConnection(this.endpoint, this.timeoutMs).catch(() => undefined);
|
|
1719
|
+
const conn = await getConnection(this.endpoint, this.timeoutMs, this.attached).catch(() => undefined);
|
|
1483
1720
|
if (!conn) return null;
|
|
1484
1721
|
try {
|
|
1485
1722
|
const res = await conn.send("script.evaluate", {
|
|
@@ -1504,15 +1741,34 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
1504
1741
|
// Drop any in-flight create record defensively: dispose() ends this endpoint's life, so a
|
|
1505
1742
|
// pending create must not leave a stale promise that a later getConnection would await.
|
|
1506
1743
|
connecting.delete(this.endpoint);
|
|
1744
|
+
// Close our multiplexer FIRST, before anything can end the real session under it. Joiners see
|
|
1745
|
+
// their sockets close (1001) and start polling the slot — which we still hold at this instant,
|
|
1746
|
+
// so they wait rather than racing us — and by the time we release below, the slot is genuinely
|
|
1747
|
+
// free for one of them to take and become the next holder. Best-effort and idempotent.
|
|
1748
|
+
const mux = muxes.get(this.endpoint);
|
|
1749
|
+
if (mux) {
|
|
1750
|
+
muxes.delete(this.endpoint);
|
|
1751
|
+
await mux.close().catch(() => undefined);
|
|
1752
|
+
}
|
|
1507
1753
|
const conn = connections.get(this.endpoint);
|
|
1508
1754
|
if (!conn) {
|
|
1509
1755
|
// No live connection, but we may still hold the session slot lock (e.g. a create acquired the
|
|
1510
1756
|
// slot then failed to establish a session on an `owned` re-dial, which leaves the claim). A
|
|
1511
1757
|
// clean dispose must free it cross-process so the next process is not falsely told we're LIVE.
|
|
1758
|
+
joined.delete(this.endpoint);
|
|
1512
1759
|
await this.releaseSessionSlotIfHeld();
|
|
1513
1760
|
return;
|
|
1514
1761
|
}
|
|
1515
1762
|
connections.delete(this.endpoint);
|
|
1763
|
+
if (joined.has(this.endpoint)) {
|
|
1764
|
+
// JOINED: this socket points at another process's mux, not at Firefox. We own neither the
|
|
1765
|
+
// real session nor any slot, so there is nothing to end and nothing to release — sending
|
|
1766
|
+
// session.end would only be answered locally by the mux, and releaseSessionSlot would at best
|
|
1767
|
+
// be a no-op. Drop the socket and forget the join. The holder's session, and our tabs, live on.
|
|
1768
|
+
joined.delete(this.endpoint);
|
|
1769
|
+
conn.dispose();
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1516
1772
|
// Cleanly END the BiDi session so Firefox frees its single session slot for the next
|
|
1517
1773
|
// attach (a launch-mode Firefox is killed anyway; this matters for attach mode where the
|
|
1518
1774
|
// user's browser lives on). Verified: closing the socket alone does NOT free the slot, and
|
|
@@ -1544,12 +1800,55 @@ export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number })
|
|
|
1544
1800
|
return new BidiBrowserDriver(`ws://127.0.0.1:${port}/session`, opts?.timeoutMs);
|
|
1545
1801
|
}
|
|
1546
1802
|
|
|
1803
|
+
/**
|
|
1804
|
+
* Become the HOLDER of this endpoint's one Firefox BiDi session and front it with a mux, for the
|
|
1805
|
+
* detached daemon in ./mux-daemon.ts. Deliberately a thin wrapper over getConnection's existing
|
|
1806
|
+
* holder path (slot acquire, dead-holder steal, Marionette orphan force-clear, dial, startBidiMux,
|
|
1807
|
+
* advertiseMux) rather than a second implementation of it — the daemon must behave EXACTLY like
|
|
1808
|
+
* the in-process holder it replaces. Requires CDP_FIREFOX_MUX=host in the environment: without it
|
|
1809
|
+
* getConnection would take the daemon path and spawn another daemon.
|
|
1810
|
+
*
|
|
1811
|
+
* Rejects if the slot is held by someone else (including another daemon that won the race, which
|
|
1812
|
+
* arrives as a JOIN — a daemon must never join, it exits), if the dial fails, or if the mux could
|
|
1813
|
+
* not be started. `dispose` closes the mux, ends the real session, and releases the slot.
|
|
1814
|
+
*/
|
|
1815
|
+
export async function hostFirefoxMux(endpoint: string, opts: { label?: string } = {}): Promise<{ mux: BidiMux; conn: BidiConnection; dispose(): Promise<void> }> {
|
|
1816
|
+
if (opts.label) slotLabelOverride = opts.label;
|
|
1817
|
+
if (process.env.CDP_FIREFOX_MUX !== "host") {
|
|
1818
|
+
throw new Error("hostFirefoxMux requires CDP_FIREFOX_MUX=host (set it before importing this module)");
|
|
1819
|
+
}
|
|
1820
|
+
const conn = await getConnection(endpoint, undefined, true);
|
|
1821
|
+
if (joined.has(endpoint)) {
|
|
1822
|
+
// Another holder (very likely a racing daemon) owns the session; we joined it. A daemon that
|
|
1823
|
+
// joins would be a second process serving a session it does not own, so refuse and let the
|
|
1824
|
+
// caller exit non-zero. Drop the socket we just opened first.
|
|
1825
|
+
joined.delete(endpoint);
|
|
1826
|
+
connections.delete(endpoint);
|
|
1827
|
+
conn.dispose();
|
|
1828
|
+
throw new Error(`endpoint ${endpoint} is already served by another holder's multiplexer`);
|
|
1829
|
+
}
|
|
1830
|
+
const mux = muxes.get(endpoint);
|
|
1831
|
+
if (!mux) {
|
|
1832
|
+
const driver = new BidiBrowserDriver(endpoint, undefined, true);
|
|
1833
|
+
await driver.dispose().catch(() => undefined);
|
|
1834
|
+
throw new Error(`could not start a BiDi multiplexer for ${endpoint}`);
|
|
1835
|
+
}
|
|
1836
|
+
const driver = new BidiBrowserDriver(endpoint, undefined, true);
|
|
1837
|
+
return {
|
|
1838
|
+
mux,
|
|
1839
|
+
conn,
|
|
1840
|
+
// BidiBrowserDriver.dispose is the ONE teardown that closes the mux first, then session.end,
|
|
1841
|
+
// then releases the slot — reused rather than re-derived so the daemon can never drift from it.
|
|
1842
|
+
dispose: () => driver.dispose(),
|
|
1843
|
+
};
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1547
1846
|
/** Construct the same driver for ATTACH mode: `wsUrl` is the BiDi endpoint of a Firefox the user
|
|
1548
1847
|
* started themselves with --remote-debugging-port, already normalized by ../backend.ts's
|
|
1549
1848
|
* normalizeBidiEndpoint. This driver NEVER owns that process — dispose() ends the BiDi session and
|
|
1550
1849
|
* closes the socket, and nothing anywhere in this path kills a browser it did not launch. */
|
|
1551
1850
|
export function createFirefoxDriverForEndpoint(wsUrl: string, opts?: { timeoutMs?: number }): BrowserDriver {
|
|
1552
|
-
return new BidiBrowserDriver(wsUrl, opts?.timeoutMs);
|
|
1851
|
+
return new BidiBrowserDriver(wsUrl, opts?.timeoutMs, true);
|
|
1553
1852
|
}
|
|
1554
1853
|
// resolveContext is exported for ONE reason: it is the Firefox lease choke
|
|
1555
1854
|
// point, and a choke point nothing can call directly is a choke point nothing
|
|
@@ -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)}`));
|