cdp-toolkit 1.10.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Firefox BiDi SESSION-slot lease: cross-process coordination for the ONE
3
+ * WebDriver-BiDi session a single Firefox instance will serve.
4
+ *
5
+ * WHY THIS EXISTS. Firefox admits exactly one active WebDriver-BiDi session per
6
+ * browser. When two cdp-toolkit MCP-server processes ATTACH to the same
7
+ * user-launched Firefox endpoint (e.g. ws://127.0.0.1:9223/session), they both
8
+ * want that one slot. Today the second process calls session.new and gets a
9
+ * hard "Maximum number of active sessions" with no way to tell a LIVE holder
10
+ * (another agent legitimately driving the browser) from a DEAD one (a crashed
11
+ * process that left the slot occupied). This module makes that distinction on
12
+ * disk, so a live holder is respected and a dead holder's orphan is reclaimed.
13
+ *
14
+ * WHY A FILE, AND WHY KEYED BY ENDPOINT. Same reasoning as the tab leases in
15
+ * ../leases.ts: two MCP servers are two processes, so in-process state would
16
+ * guard the wrong boundary. The tab lease is keyed by (backend, targetId)
17
+ * because it owns a tab; this lease is keyed by the BiDi ws ENDPOINT string
18
+ * because it owns the browser's single session slot, which is a property of the
19
+ * endpoint, not of any tab. One lock file per endpoint.
20
+ *
21
+ * WHY NO NONCE/TOKEN. A tab lease mints a nonce because a caller passes a token
22
+ * back and a reclamation must invalidate the superseded one. Nobody passes a
23
+ * session token back here: the slot is owned by whichever process currently
24
+ * holds the live BiDi connection and is released on dispose. Ownership is
25
+ * therefore identified by (pid, createdAt) of the record we wrote, which is
26
+ * enough to keep release from unlinking a record another process created after
27
+ * stealing the slot from us.
28
+ *
29
+ * WHY STALENESS IS PURE PID-LIVENESS, WITH NO TTL. A long-lived MCP server can
30
+ * legitimately hold the slot for hours, so a time-based expiry would reap a
31
+ * healthy holder. The only thing that makes a holder illegitimate is that its
32
+ * process is gone. So the sole staleness test is isPidAlive (reused from
33
+ * ../leases.ts): pid alive -> legitimate holder; pid dead -> orphan to steal.
34
+ *
35
+ * ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
36
+ * ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
37
+ * against a racing process for free, with no registry lock.
38
+ */
39
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
40
+ import { join } from "node:path";
41
+ import { isPidAlive, leaseDir } from "../leases.ts";
42
+
43
+ /**
44
+ * The on-disk record for a held session slot. Keyed by endpoint (one file per
45
+ * endpoint). No nonce: see the header. marionettePort is carried so a stealer
46
+ * can surface which Marionette port an orphaned session was bound to when it
47
+ * has to force-clear it.
48
+ */
49
+ export interface SessionSlotRecord {
50
+ endpoint: string;
51
+ /** Owning MCP server process. The one field staleness turns on. */
52
+ pid: number;
53
+ /** Caller-supplied agent label, surfaced in busy/steal reporting. */
54
+ label: string;
55
+ createdAt: number;
56
+ /** Optional Marionette control port bound to this Firefox, when known. */
57
+ marionettePort?: number;
58
+ }
59
+
60
+ /**
61
+ * What a holder keeps so it can release ITS OWN record and never another
62
+ * process's. endpoint locates the file; (pid, createdAt) identify our record.
63
+ */
64
+ export interface SessionSlotHandle {
65
+ endpoint: string;
66
+ pid: number;
67
+ createdAt: number;
68
+ }
69
+
70
+ export interface AcquireResult {
71
+ handle: SessionSlotHandle;
72
+ /**
73
+ * Set only when we STOLE a dead holder's slot. Its presence tells the caller
74
+ * that Firefox may still believe an orphaned BiDi session is active, so the
75
+ * caller must force-clear that session (e.g. via Marionette) before it calls
76
+ * session.new, or Firefox will still refuse with "Maximum number of active
77
+ * sessions". Absent on a clean first acquire.
78
+ */
79
+ staleHolder?: SessionSlotRecord;
80
+ }
81
+
82
+ export interface AcquireOptions {
83
+ label: string;
84
+ marionettePort?: number;
85
+ /** How long to keep polling a LIVE holder before giving up. Default sessionWaitMs(). */
86
+ waitMs?: number;
87
+ /** Injectable clock for the record's createdAt, so tests are deterministic. */
88
+ now?: number;
89
+ /** Poll interval while waiting on a live holder. Default DEFAULT_POLL_MS. */
90
+ pollMs?: number;
91
+ }
92
+
93
+ /** Env-overridable wait ceiling, in ms. */
94
+ const DEFAULT_SESSION_WAIT_MS = 10_000;
95
+ /** Poll cadence while a live holder is blocking us. */
96
+ const DEFAULT_POLL_MS = 250;
97
+
98
+ /** Same substitution rule as ../leases.ts safeId, applied to the endpoint URL. */
99
+ function safeEndpoint(endpoint: string): string {
100
+ return endpoint.replace(/[^A-Za-z0-9._-]/g, "_");
101
+ }
102
+
103
+ /** One lock file per endpoint, a sibling of the tab lease files in ARTIFACT_DIR. */
104
+ export function sessionSlotFile(endpoint: string): string {
105
+ return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
106
+ }
107
+
108
+ /** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
109
+ * that is not a finite number > 0 falls back to the default. */
110
+ export function sessionWaitMs(): number {
111
+ const raw = Number(process.env.CDP_FIREFOX_SESSION_WAIT_MS);
112
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_SESSION_WAIT_MS;
113
+ }
114
+
115
+ /**
116
+ * Thrown when the slot is held by a LIVE process and did not free up within the
117
+ * wait window (or the attempt cap was hit). Names the live holder so the caller
118
+ * can decide to wait and retry or point this server at a different browser.
119
+ */
120
+ export class SessionSlotBusyError extends Error {
121
+ readonly endpoint: string;
122
+ readonly holderPid?: number;
123
+ readonly holderLabel?: string;
124
+ constructor(endpoint: string, holder?: SessionSlotRecord) {
125
+ const who = holder ? `'${holder.label}' (pid ${holder.pid})` : "another live process";
126
+ super(
127
+ `Firefox BiDi session slot for endpoint '${endpoint}' is held by ${who}. ` +
128
+ `Firefox serves exactly ONE WebDriver-BiDi session per browser instance, and this slot is held ` +
129
+ `by a LIVE process, so it cannot be taken. Wait for that process to release it and retry, or point ` +
130
+ `this server at a different endpoint/browser.`,
131
+ );
132
+ this.name = "SessionSlotBusyError";
133
+ this.endpoint = endpoint;
134
+ if (holder) {
135
+ this.holderPid = holder.pid;
136
+ this.holderLabel = holder.label;
137
+ }
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Read the slot record, or undefined if the slot is genuinely free.
143
+ *
144
+ * Mirrors ../leases.ts readLease's fail-closed contract: ENOENT is "free" and
145
+ * returns undefined; any OTHER errno (EACCES, EIO, EISDIR, EMFILE...) is
146
+ * rethrown rather than reported as absent, because reporting "free" for a file
147
+ * that merely could not be read would admit a second process to a slot that is
148
+ * very much held. A record that reads but does not PARSE is treated as absent,
149
+ * deliberately: a corrupt record can never become readable, and failing closed
150
+ * on it would brick the endpoint with no in-product recovery, whereas an errno
151
+ * is transient.
152
+ */
153
+ export async function readSessionSlot(endpoint: string): Promise<SessionSlotRecord | undefined> {
154
+ let raw: string;
155
+ try {
156
+ raw = await readFile(sessionSlotFile(endpoint), "utf8");
157
+ } catch (err) {
158
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined; // genuinely free
159
+ throw err; // unreadable is not absent: fail closed
160
+ }
161
+ try {
162
+ const rec = JSON.parse(raw) as SessionSlotRecord;
163
+ if (typeof rec?.pid === "number" && typeof rec?.endpoint === "string" && typeof rec?.label === "string") {
164
+ return rec;
165
+ }
166
+ } catch {
167
+ /* unparseable: fall through to undefined, see the doc comment above */
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Take the endpoint's session slot, coordinating with any other process.
174
+ *
175
+ * Returns { handle } on a clean acquire. Returns { handle, staleHolder } when
176
+ * the previous holder's process was dead and we stole its orphaned slot (the
177
+ * caller must then force-clear Firefox's orphaned session before session.new).
178
+ * Throws SessionSlotBusyError when a LIVE holder does not free the slot inside
179
+ * the wait window.
180
+ */
181
+ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions): Promise<AcquireResult> {
182
+ const waitMs = opts.waitMs ?? sessionWaitMs();
183
+ const pollMs = opts.pollMs !== undefined && opts.pollMs > 0 ? opts.pollMs : DEFAULT_POLL_MS;
184
+ // opts.now stamps OUR record deterministically for tests; the wait deadline
185
+ // uses real time (with real sleeps) so a small waitMs actually times out.
186
+ const createdAt = opts.now ?? Date.now();
187
+ const file = sessionSlotFile(endpoint);
188
+
189
+ const record: SessionSlotRecord = {
190
+ endpoint,
191
+ pid: process.pid,
192
+ label: opts.label,
193
+ createdAt,
194
+ };
195
+ if (opts.marionettePort !== undefined) record.marionettePort = opts.marionettePort;
196
+ const handle: SessionSlotHandle = { endpoint, pid: process.pid, createdAt };
197
+ const payload = JSON.stringify(record);
198
+
199
+ await mkdir(leaseDir(), { recursive: true });
200
+
201
+ const deadline = Date.now() + waitMs;
202
+ // Bounds the immediate-retry paths (released-mid-race, steal-raced) so a
203
+ // pathological create/release partner can never spin us forever. The live
204
+ // holder is bounded by `deadline` instead; this cap is generous relative to
205
+ // it so it only bites on pathology, never on ordinary waiting.
206
+ const maxAttempts = Math.max(100, Math.ceil(waitMs / pollMs) + 100);
207
+
208
+ let attempts = 0;
209
+ let lastLiveHolder: SessionSlotRecord | undefined;
210
+
211
+ while (true) {
212
+ if (attempts >= maxAttempts) throw new SessionSlotBusyError(endpoint, lastLiveHolder);
213
+ attempts++;
214
+
215
+ // 1. Try to take the slot outright.
216
+ try {
217
+ await writeFile(file, payload, { flag: "wx" });
218
+ return { handle };
219
+ } catch (err) {
220
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
221
+ }
222
+
223
+ // 2. Occupied. Read the holder to classify it.
224
+ const holder = await readSessionSlot(endpoint);
225
+ if (!holder) continue; // released between our create and our read: retry immediately
226
+
227
+ if (!isPidAlive(holder.pid)) {
228
+ // 3. DEAD holder: orphan. Steal it — unlink, then re-create OUR record.
229
+ await unlink(file).catch(() => undefined);
230
+ try {
231
+ await writeFile(file, payload, { flag: "wx" });
232
+ return { handle, staleHolder: holder };
233
+ } catch (err) {
234
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
235
+ continue; // steal raced another process: retry immediately
236
+ }
237
+ }
238
+
239
+ // 4. LIVE holder: legitimate contention. Poll until the deadline, then fail.
240
+ lastLiveHolder = holder;
241
+ const remaining = deadline - Date.now();
242
+ if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
243
+ await sleep(Math.min(pollMs, remaining));
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Release the slot, but ONLY if the record on disk is still the one we wrote.
249
+ *
250
+ * A record is ours iff its pid is this process AND its createdAt matches the
251
+ * handle. That guard is what stops us from unlinking a record another process
252
+ * created after stealing the slot from us (dead-pid steal), which would hand
253
+ * two processes the slot at once. Idempotent: a missing record is released:false.
254
+ */
255
+ export async function releaseSessionSlot(handle: SessionSlotHandle): Promise<{ released: boolean }> {
256
+ const current = await readSessionSlot(handle.endpoint);
257
+ if (!current) return { released: false };
258
+ if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { released: false };
259
+ await unlink(sessionSlotFile(handle.endpoint)).catch(() => undefined); // swallow release races
260
+ return { released: true };
261
+ }
262
+
263
+ function sleep(ms: number): Promise<void> {
264
+ return new Promise((resolve) => setTimeout(resolve, ms));
265
+ }
package/src/cli.ts CHANGED
@@ -28,6 +28,7 @@ import { toolAvailability } from "./capabilities.ts";
28
28
  import { FIREFOX_TOOLS } from "./firefox-tools.ts";
29
29
  import { leaseFromArgs, withLeaseScope } from "./leases.ts";
30
30
  import { MANIFEST } from "./manifest.ts";
31
+ import { runInstaller } from "./install/wizard.ts";
31
32
 
32
33
  const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
33
34
 
@@ -210,6 +211,15 @@ function printToolHelp(name: ToolName): void {
210
211
 
211
212
  async function main(): Promise<number> {
212
213
  const rawArgv = process.argv.slice(2);
214
+
215
+ // `install` is a CLI-ONLY subcommand (the `cdp install` wizard), not an MCP tool: it is not in
216
+ // TOOLS/MANIFEST and never appears in --list or tools/list. Dispatch it FIRST — before --browser
217
+ // resolution, --help, and tool dispatch — so `cdp install --help` reaches the installer's own
218
+ // help rather than the global USAGE, and so nothing below can reinterpret its flags.
219
+ if (rawArgv[0] === "install") {
220
+ return await runInstaller(rawArgv.slice(1));
221
+ }
222
+
213
223
  const { kind: browser, endpoint } = resolveBackend(rawArgv);
214
224
  const parsed = parseArgv(stripConnectFlag(stripBrowserFlag(rawArgv)));
215
225