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.
- package/CHANGELOG.md +20 -0
- package/README.md +29 -7
- package/dist/cli.js +902 -13
- package/dist/mcp.js +340 -14
- package/package.json +2 -1
- package/src/bidi/driver.ts +190 -28
- package/src/bidi/marionette.ts +276 -0
- package/src/bidi/session-lease.ts +265 -0
- package/src/cli.ts +10 -0
- package/src/install/browser.ts +211 -0
- package/src/install/harness.ts +224 -0
- package/src/install/wizard.ts +412 -0
- package/src/mcp.ts +1 -1
package/src/bidi/driver.ts
CHANGED
|
@@ -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.
|
|
329
|
-
//
|
|
330
|
-
//
|
|
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
|
-
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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)
|
|
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
|
+
}
|