cdp-toolkit 1.10.0 → 1.11.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.
@@ -40,11 +40,16 @@ import {
40
40
  type ScreenshotOptions, type ScreenshotResult, type ScrollOptions, type SnapshotNode, type UidStability,
41
41
  type BandConsumer, type CaptureDelivery,
42
42
  } from "../driver.ts";
43
- import { assertLeaseOk } from "../leases.ts";
43
+ import { assertLeaseOk, defaultLabel } from "../leases.ts";
44
44
  import { resolveLiveLabel } from "../origins.ts";
45
45
  import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
46
46
  import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
47
47
  import { BidiConnection, BidiError, connectBidiSessionUrl } from "./client.ts";
48
+ import {
49
+ acquireSessionSlot, releaseSessionSlot, SessionSlotBusyError,
50
+ type AcquireResult, type SessionSlotHandle,
51
+ } from "./session-lease.ts";
52
+ import { defaultMarionettePort, forceClearOrphanSession } from "./marionette.ts";
48
53
  import { takeStampedSnapshot } from "./snapshot.ts";
49
54
  import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
50
55
  import type {
@@ -320,39 +325,174 @@ const BIDI_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
320
325
  // the only identity both modes share. getConnection retains a holder on every call (fresh or
321
326
  // reused); callers release() it.
322
327
  const connections = new Map<string, BidiConnection>();
328
+ // This process's OWN Firefox BiDi session-slot ownership, keyed by endpoint (see session-lease.ts).
329
+ // The slot LOCK is a file on disk — that is the cross-process source of truth — and this map is our
330
+ // in-process record of which endpoints WE currently hold a live claim on, so dispose() releases
331
+ // exactly the slots we took, and a re-dial after a dead socket reuses our own claim instead of
332
+ // contending with our own pid. Populated in getConnection's create body; cleared on dispose() and
333
+ // on a terminal dial failure that never established a session.
334
+ const slotHandles = new Map<string, SessionSlotHandle>();
335
+ // In-flight create dedup, keyed by endpoint. Two page() acquisitions can both cache-miss and call
336
+ // getConnection concurrently; without this both would run the acquire+dial body, and the second
337
+ // would either contend for the slot we just took (same pid → wait then SessionSlotBusyError against
338
+ // ourselves) or open a second BiDi session. The first caller stores its create-promise here and
339
+ // every other awaits it; cleared in a finally once the create settles.
340
+ const connecting = new Map<string, Promise<BidiConnection>>();
341
+
342
+ /**
343
+ * Release a slot claim we took in THIS getConnection call after a dial that never established a
344
+ * live session, so a later attempt re-acquires cleanly. When `owned` is true we did NOT acquire in
345
+ * this call (we reused a pre-existing claim — our previous socket died without dispose), so the
346
+ * handle predates this call and must be left intact: we still own Firefox's slot, only the socket
347
+ * failed to re-establish.
348
+ */
349
+ async function releaseSlotOnDialFailure(endpoint: string, owned: boolean): Promise<void> {
350
+ if (owned) return;
351
+ const h = slotHandles.get(endpoint);
352
+ if (h) {
353
+ await releaseSessionSlot(h).catch(() => undefined);
354
+ slotHandles.delete(endpoint);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * The actionable error for issue #4: Firefox is holding an orphaned BiDi session on this endpoint
360
+ * that Marionette recovery could NOT auto-clear (Firefox launched without --marionette, or the
361
+ * Marionette channel timed out / could not confirm). Distinguished from the live-busy case (issue
362
+ * #5, a LIVE lease holder) because here NO live process holds our lease — a session Firefox itself
363
+ * stranded is wedging the slot, and the fix is to enable Marionette or restart Firefox.
364
+ */
365
+ function orphanNotClearedError(endpoint: string): DriverError {
366
+ return driverError("disconnected",
367
+ `Firefox holds an orphaned WebDriver BiDi session on ${endpoint} that could not be auto-cleared. ` +
368
+ `Marionette recovery needs Firefox launched with --marionette (default port 2828, or set ` +
369
+ `CDP_FIREFOX_MARIONETTE_PORT); otherwise restart Firefox to free the slot. (A client killed without ` +
370
+ `session.end can leave a session Firefox does not reap.)`);
371
+ }
372
+
373
+ // getConnection: the ONE Firefox-session choke point. Beyond memoizing a BidiConnection per endpoint
374
+ // it now runs the cross-process SESSION-slot coordination (session-lease.ts) plus Marionette orphan
375
+ // recovery (marionette.ts). Firefox serves exactly ONE WebDriver-BiDi session per browser, and it
376
+ // never reaps an abandoned one (verified: still held 10s after an abrupt socket death). Two ills
377
+ // follow that this closes: (#5) two LIVE processes attaching to one user-launched Firefox used to
378
+ // hard-wedge on the raw "Maximum number of active sessions" with no way to tell a live holder from a
379
+ // crash; the lease now names the LIVE holder and says wait-and-retry. (#4) a process killed without
380
+ // session.end used to leave the slot occupied until Firefox restarted; when the lease shows the
381
+ // prior holder's PID is DEAD, or Firefox itself still refuses on a re-dial, a blind Marionette
382
+ // WebDriver:DeleteSession (which shares Firefox's one session slot) force-clears the orphan WITHOUT
383
+ // killing the user's browser, and we retry. Marionette recovery is best-effort and degrades to a
384
+ // clear, actionable error when Firefox has no --marionette port (the launch-mode norm).
323
385
  async function getConnection(endpoint: string, timeoutMs?: number): Promise<BidiConnection> {
324
386
  const existing = connections.get(endpoint);
325
387
  if (existing && existing.isOpen) { existing.retain(); return existing; }
326
388
  // A cached connection whose socket has died (ws.onclose fired) can only reject every
327
389
  // send() with "connection not open" — reusing it would strand the endpoint permanently.
328
- // Evict it so the code below re-dials and creates a fresh BiDi session. No cleanup is
329
- // needed: a dead connection already had `closed` set and rejectAll() cleared its per-call
330
- // timers, and BidiConnection holds no heartbeat/keepalive resource to leak (see client.ts).
390
+ // Evict it so the code below re-dials and creates a fresh BiDi session. We deliberately do NOT
391
+ // release the session slot here: a dead socket is transient and WE still own Firefox's slot, so
392
+ // the create body below re-dials WITHOUT re-acquiring (its `owned` branch), avoiding a needless
393
+ // contention against our own pid. No other cleanup is needed: a dead connection already had
394
+ // `closed` set and rejectAll() cleared its per-call timers, and BidiConnection holds no
395
+ // heartbeat/keepalive resource to leak (see client.ts).
331
396
  if (existing) connections.delete(endpoint);
332
- // Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
333
- // gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
334
- // reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
335
- // next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
336
- // and answer a live prompt at all.
337
- const conn = await connectBidiSessionUrl(endpoint, {
338
- ...(timeoutMs !== undefined ? { timeoutMs } : {}),
339
- capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" } } },
340
- }).catch((e: unknown) => {
341
- // Firefox allows exactly ONE active BiDi session and never reaps an abandoned one (verified:
342
- // still held 10s after an abrupt socket death). A client killed without session.end therefore
343
- // leaves the slot occupied until Firefox restarts, and the raw wire message ("Maximum number of
344
- // active sessions") tells the user nothing about how to get unstuck. Force-ending a session we
345
- // do not own is not an option (there is no id to end), so this maps the error to instructions.
346
- if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
347
- throw driverError("disconnected",
348
- `Firefox already has an active WebDriver BiDi session on ${endpoint}; it allows only one. ` +
349
- `Close the other client, or restart Firefox, then retry. (A previously killed client can leave a ` +
350
- `session that Firefox does not reap.)`);
397
+
398
+ // Concurrency: dedup in-flight creates on this endpoint. An awaiter shares the create's outcome
399
+ // but needs its OWN holder — the create's connectBidiSessionUrl retains exactly once, for the
400
+ // caller that started it, so every other caller retains again for itself (callers all release()).
401
+ const inflight = connecting.get(endpoint);
402
+ if (inflight) {
403
+ const conn = await inflight;
404
+ conn.retain();
405
+ return conn;
406
+ }
407
+
408
+ const create = (async (): Promise<BidiConnection> => {
409
+ const mport = defaultMarionettePort();
410
+ // Do we ALREADY hold this endpoint's slot? (e.g. our previous socket died without dispose, so
411
+ // the eviction above kept the claim.) If so, re-dial WITHOUT re-acquiring: acquireSessionSlot
412
+ // against our own live pid would just poll to its wait ceiling and then throw
413
+ // SessionSlotBusyError against ourselves. We already own Firefox's slot; only the socket needs
414
+ // re-establishing (and if Firefox stranded our old session, the re-dial's retry path clears it).
415
+ const owned = slotHandles.has(endpoint);
416
+ if (!owned) {
417
+ // Cross-process coordination (session-lease.ts): take the endpoint's single BiDi session slot,
418
+ // classifying any current holder. A LIVE holder (another agent legitimately driving this
419
+ // Firefox) throws SessionSlotBusyError; a DEAD holder's orphan is stolen and surfaced as
420
+ // staleHolder so we can force-clear Firefox's stranded session before we call session.new.
421
+ let acq: AcquireResult;
422
+ try {
423
+ acq = await acquireSessionSlot(endpoint, { label: defaultLabel(), marionettePort: mport });
424
+ } catch (e) {
425
+ if (e instanceof SessionSlotBusyError) {
426
+ // Issue #5: a distinguishable LIVE holder, NOT a wedge. Map to an actionable disconnected
427
+ // error that names who holds the one session and why Firefox can serve only one per browser.
428
+ const who = e.holderLabel ? `'${e.holderLabel}' (pid ${e.holderPid})` : "another live process";
429
+ throw driverError("disconnected",
430
+ `Firefox's single WebDriver BiDi session on ${endpoint} is held by a LIVE process ${who}. ` +
431
+ `Firefox serves exactly ONE BiDi session per browser instance, so this server cannot take it ` +
432
+ `while that process is running. Wait for it to release the session and retry, or point this ` +
433
+ `server at a different endpoint/browser.`);
434
+ }
435
+ throw e;
436
+ }
437
+ slotHandles.set(endpoint, acq.handle);
438
+ if (acq.staleHolder) {
439
+ // Issue #4 auto-recovery: we stole a DEAD holder's lease, but Firefox itself may still hold
440
+ // that holder's orphaned BiDi session. Force-clear it over Marionette BEFORE dialing, or
441
+ // session.new below will still hit "Maximum number of active sessions". Best-effort: a false
442
+ // result means Firefox has no --marionette (launch mode, or the user never enabled it); we
443
+ // still dial, and the re-dial retry path below surfaces a clear error if the orphan persists.
444
+ await forceClearOrphanSession(acq.staleHolder.marionettePort ?? mport);
445
+ }
351
446
  }
352
- throw e;
353
- }); // already retained once
354
- connections.set(endpoint, conn);
355
- return conn;
447
+
448
+ // Firefox 153's default unhandledPromptBehavior is "dismiss": a page-blocking alert()/confirm()
449
+ // gets auto-dismissed before handleDialog() ever runs, and Firefox's own userPromptOpened event
450
+ // reports handler:"dismiss" on it (verified: a bare session.new left "no such alert" on the very
451
+ // next handleUserPrompt call). Setting "ignore" here is what makes handleDialog() able to see
452
+ // and answer a live prompt at all.
453
+ const dialOpts = {
454
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
455
+ capabilities: { alwaysMatch: { unhandledPromptBehavior: { default: "ignore" as const } } },
456
+ };
457
+ let conn: BidiConnection;
458
+ try {
459
+ conn = await connectBidiSessionUrl(endpoint, dialOpts); // already retained once
460
+ } catch (e) {
461
+ if (e instanceof BidiError && /maximum number of active sessions/i.test(e.message)) {
462
+ // Firefox still holds a BiDi session our lease didn't account for: a FOREIGN client we don't
463
+ // coordinate with, OUR OWN orphan from a dead socket (the `owned` re-dial), or a pre-lease
464
+ // orphan. Recovery: blind Marionette force-clear, then retry the dial EXACTLY once.
465
+ const cleared = await forceClearOrphanSession(mport);
466
+ if (!cleared) {
467
+ await releaseSlotOnDialFailure(endpoint, owned);
468
+ throw orphanNotClearedError(endpoint);
469
+ }
470
+ try {
471
+ conn = await connectBidiSessionUrl(endpoint, dialOpts); // already retained once
472
+ } catch (e2) {
473
+ await releaseSlotOnDialFailure(endpoint, owned);
474
+ if (e2 instanceof BidiError && /maximum number of active sessions/i.test(e2.message)) {
475
+ throw orphanNotClearedError(endpoint); // cleared, yet still refused: treat as unrecoverable
476
+ }
477
+ throw e2;
478
+ }
479
+ } else {
480
+ // Any other dial failure never established a session: release a slot we took THIS call and
481
+ // rethrow unchanged (the `owned` re-dial never acquired, so its claim is left intact).
482
+ await releaseSlotOnDialFailure(endpoint, owned);
483
+ throw e;
484
+ }
485
+ }
486
+ connections.set(endpoint, conn);
487
+ return conn;
488
+ })();
489
+
490
+ connecting.set(endpoint, create);
491
+ try {
492
+ return await create;
493
+ } finally {
494
+ connecting.delete(endpoint);
495
+ }
356
496
  }
357
497
 
358
498
  /**
@@ -1333,8 +1473,17 @@ class BidiBrowserDriver implements BrowserDriver {
1333
1473
  for (const key of [...bidiBeaconScripts.keys()]) {
1334
1474
  if (key.startsWith(`${this.endpoint}:`)) bidiBeaconScripts.delete(key);
1335
1475
  }
1476
+ // Drop any in-flight create record defensively: dispose() ends this endpoint's life, so a
1477
+ // pending create must not leave a stale promise that a later getConnection would await.
1478
+ connecting.delete(this.endpoint);
1336
1479
  const conn = connections.get(this.endpoint);
1337
- if (!conn) return;
1480
+ if (!conn) {
1481
+ // No live connection, but we may still hold the session slot lock (e.g. a create acquired the
1482
+ // slot then failed to establish a session on an `owned` re-dial, which leaves the claim). A
1483
+ // clean dispose must free it cross-process so the next process is not falsely told we're LIVE.
1484
+ await this.releaseSessionSlotIfHeld();
1485
+ return;
1486
+ }
1338
1487
  connections.delete(this.endpoint);
1339
1488
  // Cleanly END the BiDi session so Firefox frees its single session slot for the next
1340
1489
  // attach (a launch-mode Firefox is killed anyway; this matters for attach mode where the
@@ -1343,6 +1492,19 @@ class BidiBrowserDriver implements BrowserDriver {
1343
1492
  // Best-effort: harmless if the socket is already gone.
1344
1493
  await conn.send("session.end", {}).catch(() => undefined);
1345
1494
  conn.dispose();
1495
+ // Release OUR cross-process session-slot lock so the next process (this or another) can take the
1496
+ // slot cleanly. releaseSessionSlot only unlinks a record still stamped with our (pid, createdAt),
1497
+ // so it never clobbers a claim a later process made after stealing ours. (session-lease.ts)
1498
+ await this.releaseSessionSlotIfHeld();
1499
+ }
1500
+
1501
+ /** Free this endpoint's session-slot lock if this process holds it, and forget the handle.
1502
+ * Best-effort and idempotent — safe to call whether or not a live connection existed. */
1503
+ private async releaseSessionSlotIfHeld(): Promise<void> {
1504
+ const h = slotHandles.get(this.endpoint);
1505
+ if (!h) return;
1506
+ await releaseSessionSlot(h).catch(() => undefined);
1507
+ slotHandles.delete(this.endpoint);
1346
1508
  }
1347
1509
  }
1348
1510
 
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Minimal Firefox Marionette client — orphan BiDi-session recovery, issue #4.
3
+ *
4
+ * WHAT THIS IS FOR, AND NOTHING ELSE. Firefox serves exactly ONE
5
+ * WebDriver-BiDi session per browser instance, and Marionette and BiDi SHARE
6
+ * that one global session slot. When a cdp-toolkit process dies mid-drive it can
7
+ * leave Firefox believing a BiDi session is still active; the next `session.new`
8
+ * then fails with "Maximum number of active sessions" even though no live
9
+ * process holds it. `session-lease.ts` detects that a dead process left the slot
10
+ * occupied (its `staleHolder`); THIS module is the mechanism that then frees the
11
+ * slot inside Firefox itself, WITHOUT killing the browser — so a user's real,
12
+ * logged-in Firefox keeps running and only the orphaned session is cleared.
13
+ *
14
+ * WHY MARIONETTE AND NOT BIDI. The BiDi endpoint is what's wedged: it refuses a
15
+ * new session while the orphan is "active", and it exposes no cross-session
16
+ * teardown. Marionette is Firefox's OTHER, older remote-control channel (a raw
17
+ * TCP port, `--marionette`, default 2828). Because the two dialects share the
18
+ * one session slot, a single Marionette `WebDriver:DeleteSession` tears down
19
+ * whatever occupies that slot — the orphaned BiDi session included — as a blind
20
+ * cross-dialect teardown. No `WebDriver:NewSession` is needed first: DeleteSession
21
+ * against an empty slot is a harmless no-op ("invalid session id").
22
+ *
23
+ * THE WIRE PROTOCOL ("packet" dialect, marionetteProtocol 3), proven against
24
+ * Firefox 153.0.3:
25
+ * - Framing, both directions: `<utf8-byte-length>:<json>` — the ASCII decimal
26
+ * byte count of the JSON body, a literal ':', then exactly that many bytes of
27
+ * UTF-8 JSON. Frames coalesce and split across TCP reads; the decoder handles
28
+ * both (see FrameDecoder).
29
+ * - On connect, Firefox immediately PUSHES a handshake frame (a bare object,
30
+ * e.g. `50:{"applicationType":"gecko","marionetteProtocol":3}`) that a client
31
+ * must consume before anything else.
32
+ * - Command frame: `[0, msgId, "Command:Name", params]`.
33
+ * - Response frame: `[1, msgId, error_or_null, result_or_null]`.
34
+ *
35
+ * ZERO RUNTIME DEPENDENCIES: only `node:net`.
36
+ */
37
+ import { Socket } from "node:net";
38
+
39
+ /** Firefox's default Marionette control port. */
40
+ const DEFAULT_MARIONETTE_PORT = 2828;
41
+ /** Whole-exchange budget: connect + handshake + command + reply. */
42
+ const DEFAULT_TIMEOUT_MS = 4000;
43
+ /** Marionette listens on loopback only; never dial anything else. */
44
+ const HOST = "127.0.0.1";
45
+ /** ASCII ':' — the length/body delimiter in the packet framing. */
46
+ const COLON = 0x3a;
47
+
48
+ /**
49
+ * The one command we ever send. Marionette + BiDi share the single session slot,
50
+ * so this blindly tears down whatever occupies it (the orphaned BiDi session)
51
+ * with no NewSession first. Precomputed once: `34:[0,1,"WebDriver:DeleteSession",{}]`.
52
+ */
53
+ const DELETE_SESSION_FRAME = encodeFrame([0, 1, "WebDriver:DeleteSession", {}]);
54
+
55
+ /**
56
+ * The Marionette control port to target. Reads `CDP_FIREFOX_MARIONETTE_PORT`
57
+ * per call (like session-lease.ts's env getters, so a test can redirect it); a
58
+ * value that is not a finite number > 0 falls back to Firefox's 2828 default.
59
+ */
60
+ export function defaultMarionettePort(): number {
61
+ const raw = Number(process.env.CDP_FIREFOX_MARIONETTE_PORT);
62
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MARIONETTE_PORT;
63
+ }
64
+
65
+ /**
66
+ * Encode one value as a Marionette wire frame: `<utf8-byte-length>:<utf8-json>`.
67
+ * The length prefix is the BYTE count of the JSON body (not its character
68
+ * count), so multi-byte UTF-8 payloads frame correctly. Pure; exported for tests.
69
+ */
70
+ export function encodeFrame(json: unknown): Buffer {
71
+ const body = Buffer.from(JSON.stringify(json), "utf8");
72
+ const prefix = Buffer.from(`${body.length}:`, "ascii");
73
+ return Buffer.concat([prefix, body]);
74
+ }
75
+
76
+ /**
77
+ * Raised when the byte stream is not valid Marionette framing — a non-digit in
78
+ * the length prefix, or a frame body that is complete but not parseable JSON.
79
+ * FrameDecoder.next() throws this; forceClearOrphanSession catches it and
80
+ * degrades to `false` (a wedged/unparseable channel cannot confirm a free slot).
81
+ */
82
+ export class MarionetteProtocolError extends Error {
83
+ constructor(message: string) {
84
+ super(message);
85
+ this.name = "MarionetteProtocolError";
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Incremental decoder for the `<byte-length>:<json>` framing. Feed raw TCP
91
+ * chunks with push(); pull one complete, parsed frame per next() call.
92
+ *
93
+ * Handles the two things naive framing gets wrong on a real socket: a single
94
+ * frame SPLIT across several reads (next() returns undefined until the whole
95
+ * body has arrived) and several frames COALESCED into one read (repeated next()
96
+ * calls drain them one at a time). Exported for unit testing against known
97
+ * byte vectors.
98
+ */
99
+ export class FrameDecoder {
100
+ private buf: Buffer = Buffer.alloc(0);
101
+
102
+ /** Append a chunk of bytes. Cheap: no scan happens here. */
103
+ push(chunk: Buffer): void {
104
+ this.buf = this.buf.length === 0 ? chunk : Buffer.concat([this.buf, chunk]);
105
+ }
106
+
107
+ /**
108
+ * Return the next complete frame as its parsed value, or undefined when more
109
+ * bytes are still needed to complete the frame currently at the front of the
110
+ * buffer. Throws MarionetteProtocolError on malformed framing/JSON.
111
+ */
112
+ next(): unknown | undefined {
113
+ const colon = this.buf.indexOf(COLON);
114
+ if (colon === -1) {
115
+ // No delimiter yet. Everything buffered so far must be ASCII digits, or
116
+ // this is not Marionette framing and never will be — fail fast instead of
117
+ // buffering garbage until the caller's timeout.
118
+ this.assertDigitsPrefix(this.buf.length);
119
+ return undefined;
120
+ }
121
+ if (colon === 0) throw new MarionetteProtocolError("empty frame length prefix");
122
+ this.assertDigitsPrefix(colon);
123
+
124
+ const len = Number.parseInt(this.buf.toString("ascii", 0, colon), 10);
125
+ const start = colon + 1;
126
+ const end = start + len;
127
+ if (this.buf.length < end) return undefined; // body not fully arrived yet
128
+
129
+ const payload = this.buf.toString("utf8", start, end);
130
+ this.buf = this.buf.subarray(end); // keep any trailing bytes for the next frame
131
+ try {
132
+ return JSON.parse(payload);
133
+ } catch {
134
+ throw new MarionetteProtocolError(
135
+ `frame body was not valid JSON (${len} bytes): ${payload.slice(0, 80)}`,
136
+ );
137
+ }
138
+ }
139
+
140
+ /** Every byte in buf[0, upTo) must be an ASCII digit, or the framing is bad. */
141
+ private assertDigitsPrefix(upTo: number): void {
142
+ for (let i = 0; i < upTo; i++) {
143
+ const b = this.buf[i]!;
144
+ if (b < 0x30 || b > 0x39) {
145
+ throw new MarionetteProtocolError(
146
+ `non-digit byte 0x${b.toString(16)} in frame length prefix`,
147
+ );
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Interpret a `WebDriver:DeleteSession` reply frame `[1, msgId, error, result]`.
155
+ * The slot is FREE (return true) in exactly two shapes:
156
+ * - error === null (result `{"value":null}`): an active session existed and
157
+ * was cleared.
158
+ * - error is an object with error === "invalid session id": the slot was
159
+ * already empty — DeleteSession was a no-op.
160
+ * Any other shape (a different error, a non-response, a malformed frame) cannot
161
+ * confirm the slot is free, so it returns false.
162
+ */
163
+ function isSlotFreedReply(frame: unknown): boolean {
164
+ if (!Array.isArray(frame)) return false;
165
+ const err: unknown = frame[2];
166
+ if (err === null) return true; // session existed and was cleared
167
+ if (typeof err === "object" && err !== null && (err as { error?: unknown }).error === "invalid session id") {
168
+ return true; // slot was already empty
169
+ }
170
+ return false; // any other error: cannot confirm
171
+ }
172
+
173
+ /**
174
+ * Force-clear an orphaned WebDriver-BiDi session so a subsequent BiDi
175
+ * `session.new` succeeds, WITHOUT killing Firefox.
176
+ *
177
+ * Connects to Marionette on `127.0.0.1:<marionettePort>`, drains the pushed
178
+ * handshake frame, sends exactly one `WebDriver:DeleteSession`, reads exactly one
179
+ * reply, and returns whether the session slot is now free.
180
+ *
181
+ * NEVER THROWS and NEVER LEAKS THE SOCKET. Every terminal path — success, a
182
+ * refused connection (Marionette not running because Firefox was not launched
183
+ * with --marionette; the NORMAL degrade case, not a loud error), a connect/read
184
+ * timeout, any socket error, a premature close, or an unexpected/unparseable
185
+ * reply — routes through a single settle() that destroys the socket exactly once
186
+ * and resolves the promise. Returns true only when the slot is confirmed free.
187
+ *
188
+ * @param marionettePort TCP port Firefox's Marionette server listens on.
189
+ * @param opts.timeoutMs Whole-exchange budget (connect+handshake+command+reply).
190
+ * Default 4000ms. On timeout the socket is destroyed and
191
+ * the result is false.
192
+ */
193
+ export function forceClearOrphanSession(
194
+ marionettePort: number,
195
+ opts: { timeoutMs?: number } = {},
196
+ ): Promise<boolean> {
197
+ const timeoutMs =
198
+ opts.timeoutMs !== undefined && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
199
+ ? opts.timeoutMs
200
+ : DEFAULT_TIMEOUT_MS;
201
+
202
+ return new Promise<boolean>((resolve) => {
203
+ // Guard a nonsense port up front: net.Socket.connect throws synchronously on
204
+ // an out-of-range/NaN port, and that throw must not escape as a rejection.
205
+ if (!Number.isFinite(marionettePort) || marionettePort <= 0 || marionettePort > 65535) {
206
+ resolve(false);
207
+ return;
208
+ }
209
+
210
+ const sock = new Socket();
211
+ const decoder = new FrameDecoder();
212
+ let settled = false;
213
+ let handshakeSeen = false;
214
+ let commandSent = false;
215
+
216
+ // One-shot terminal path. Idempotent (settled guard), removes listeners so a
217
+ // close/error triggered by our own destroy() can't re-enter, and ALWAYS
218
+ // destroys the socket — this is the single place the socket is torn down, so
219
+ // no code path can return without freeing it.
220
+ const settle = (value: boolean): void => {
221
+ if (settled) return;
222
+ settled = true;
223
+ clearTimeout(timer);
224
+ sock.removeAllListeners();
225
+ sock.destroy();
226
+ resolve(value);
227
+ };
228
+
229
+ // The timeout covers the WHOLE exchange, including connect. unref() so a
230
+ // hosting process is never held open by this timer alone.
231
+ const timer = setTimeout(() => settle(false), timeoutMs);
232
+ if (typeof timer.unref === "function") timer.unref();
233
+
234
+ // ECONNREFUSED (no Marionette on the port) and every other socket error are
235
+ // the normal degrade to false — quiet, not logged.
236
+ sock.on("error", () => settle(false));
237
+ // Peer closed before we got a reply: can't confirm, degrade to false.
238
+ sock.on("close", () => settle(false));
239
+
240
+ sock.on("data", (chunk: Buffer) => {
241
+ if (settled) return;
242
+ try {
243
+ decoder.push(chunk);
244
+ for (;;) {
245
+ const frame = decoder.next();
246
+ if (frame === undefined) break; // need more bytes for the front frame
247
+ if (!handshakeSeen) {
248
+ // First frame is Firefox's handshake (a bare object). Consume and
249
+ // discard it, then keep draining in case a later frame coalesced.
250
+ handshakeSeen = true;
251
+ continue;
252
+ }
253
+ // First post-handshake frame is our DeleteSession reply. Done.
254
+ settle(isSlotFreedReply(frame));
255
+ return;
256
+ }
257
+ // Handshake in hand and command not yet on the wire → send it once.
258
+ if (handshakeSeen && !commandSent) {
259
+ commandSent = true;
260
+ sock.write(DELETE_SESSION_FRAME);
261
+ }
262
+ } catch {
263
+ // Malformed framing/JSON: the channel can't confirm a free slot.
264
+ settle(false);
265
+ }
266
+ });
267
+
268
+ // connect() can still throw synchronously (e.g. a mid-range but invalid
269
+ // arg); route that through settle so nothing escapes as a rejection.
270
+ try {
271
+ sock.connect(marionettePort, HOST);
272
+ } catch {
273
+ settle(false);
274
+ }
275
+ });
276
+ }