cdp-toolkit 2.1.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.
@@ -35,8 +35,34 @@
35
35
  * ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
36
36
  * ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
37
37
  * against a racing process for free, with no registry lock.
38
+ *
39
+ * THE MUX: WHY A LIVE HOLDER IS NO LONGER A DEAD END. Everything above still
40
+ * holds — one process owns Firefox's single BiDi session — but the holder now
41
+ * also hosts a loopback BiDi multiplexer (./mux.ts) over that one real session
42
+ * and ADVERTISES its ws endpoint in this record (`muxEndpoint`). Two consequences
43
+ * for this module, and nothing else changes:
44
+ *
45
+ * - THE HOLDER advertises via advertiseMux() once its mux is listening. That is
46
+ * a rewrite of its OWN record, guarded by the same (pid, createdAt) ownership
47
+ * test release uses, and written atomically (tmp + rename) so a concurrent
48
+ * reader never sees a half-written record. Advertising is best-effort: a
49
+ * refusal (`advertised:false`) means the record is no longer ours and the
50
+ * holder simply runs alone.
51
+ * - A JOINER DOES NOT WAIT. When acquire finds a LIVE holder that carries a
52
+ * muxEndpoint, there is nothing to wait FOR: the slot will not free up (the
53
+ * holder is healthy) and it does not need to, because the mux serves that one
54
+ * session to as many processes as ask. So acquire returns { joinMux }
55
+ * immediately, with NO handle and nothing written to disk — the joiner holds
56
+ * no slot and must never release one. The record is re-read on every poll
57
+ * iteration, so a holder that took the slot a moment ago and advertises a beat
58
+ * later is joined as soon as it does, inside the existing wait window.
59
+ *
60
+ * A live holder with NO muxEndpoint (a foreign client, an older cdp-toolkit, or
61
+ * one running with CDP_FIREFOX_MUX=off) is still plain BUSY and still throws
62
+ * SessionSlotBusyError at the deadline — exactly today's behavior. CDP_FIREFOX_MUX=off
63
+ * makes acquire ignore muxEndpoint entirely, which is the opt-out.
38
64
  */
39
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
65
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
40
66
  import { join } from "node:path";
41
67
  import { isPidAlive, leaseDir } from "../leases.ts";
42
68
 
@@ -55,6 +81,12 @@ export interface SessionSlotRecord {
55
81
  createdAt: number;
56
82
  /** Optional Marionette control port bound to this Firefox, when known. */
57
83
  marionettePort?: number;
84
+ /**
85
+ * Loopback ws endpoint of the BiDi multiplexer this holder hosts over the one
86
+ * real session (see ./mux.ts and the header). Absent means "this holder serves
87
+ * nobody but itself", which is what every pre-mux client's record looks like.
88
+ */
89
+ muxEndpoint?: string;
58
90
  }
59
91
 
60
92
  /**
@@ -68,7 +100,11 @@ export interface SessionSlotHandle {
68
100
  }
69
101
 
70
102
  export interface AcquireResult {
71
- handle: SessionSlotHandle;
103
+ /**
104
+ * Present iff WE took the slot. Absent on the joinMux path: a joiner owns no
105
+ * slot, wrote no record, and must never release one.
106
+ */
107
+ handle?: SessionSlotHandle;
72
108
  /**
73
109
  * Set only when we STOLE a dead holder's slot. Its presence tells the caller
74
110
  * that Firefox may still believe an orphaned BiDi session is active, so the
@@ -77,6 +113,12 @@ export interface AcquireResult {
77
113
  * sessions". Absent on a clean first acquire.
78
114
  */
79
115
  staleHolder?: SessionSlotRecord;
116
+ /**
117
+ * Set instead of `handle` when a LIVE holder advertises a BiDi multiplexer.
118
+ * The caller dials `endpoint` exactly as it would dial Firefox; `holder` is the
119
+ * record it came from, for error reporting. Nothing was written to disk.
120
+ */
121
+ joinMux?: { endpoint: string; holder: SessionSlotRecord };
80
122
  }
81
123
 
82
124
  export interface AcquireOptions {
@@ -105,6 +147,16 @@ export function sessionSlotFile(endpoint: string): string {
105
147
  return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
106
148
  }
107
149
 
150
+ /**
151
+ * Where the detached mux DAEMON (./mux-daemon.ts) appends its one-line diagnostics for this
152
+ * endpoint. It is spawned with stdio:"ignore" (no console at all), so this file is the ONLY
153
+ * record of why a daemon refused to start; the driver names it in the error it raises when a
154
+ * daemon it spawned never advertised. Sibling of the slot file, same safeEndpoint key.
155
+ */
156
+ export function muxDaemonLogFile(endpoint: string): string {
157
+ return join(leaseDir(), `ff-mux-${safeEndpoint(endpoint)}.log`);
158
+ }
159
+
108
160
  /** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
109
161
  * that is not a finite number > 0 falls back to the default. */
110
162
  export function sessionWaitMs(): number {
@@ -236,7 +288,16 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
236
288
  }
237
289
  }
238
290
 
239
- // 4. LIVE holder: legitimate contention. Poll until the deadline, then fail.
291
+ // 4. LIVE holder. If it advertises a mux, JOIN it instead of waiting: the slot
292
+ // is never going to free up, and it does not have to — the holder's mux serves
293
+ // the one real session to every joiner. Return immediately, writing nothing.
294
+ // (The re-read at step 2 happens every iteration, so a holder that advertises a
295
+ // beat after taking the slot is picked up on the next poll.) CDP_FIREFOX_MUX=off
296
+ // opts out and restores the pre-mux busy-wait exactly.
297
+ if (typeof holder.muxEndpoint === "string" && holder.muxEndpoint.length > 0 && process.env.CDP_FIREFOX_MUX !== "off") {
298
+ return { joinMux: { endpoint: holder.muxEndpoint, holder } };
299
+ }
300
+ // No mux: legitimate contention. Poll until the deadline, then fail.
240
301
  lastLiveHolder = holder;
241
302
  const remaining = deadline - Date.now();
242
303
  if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
@@ -244,6 +305,37 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
244
305
  }
245
306
  }
246
307
 
308
+ /**
309
+ * Publish this holder's BiDi multiplexer endpoint into its OWN slot record, so a
310
+ * contending process joins the mux instead of waiting out the slot.
311
+ *
312
+ * Ownership-guarded by the same (pid, createdAt) test releaseSessionSlot uses: if
313
+ * the record on disk is missing or belongs to someone else (we were stolen from
314
+ * as a dead-looking holder, or we already released), we refuse with
315
+ * { advertised: false } and leave the file byte-for-byte untouched rather than
316
+ * stamping our mux onto a stranger's claim.
317
+ *
318
+ * Atomic: the merged record is written to `<file>.tmp-<pid>` and renamed over the
319
+ * original, so a concurrent acquire's read sees either the old record or the new
320
+ * one, never a truncated one. Every other field is preserved.
321
+ */
322
+ export async function advertiseMux(handle: SessionSlotHandle, muxEndpoint: string): Promise<{ advertised: boolean }> {
323
+ const current = await readSessionSlot(handle.endpoint);
324
+ if (!current) return { advertised: false };
325
+ if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { advertised: false };
326
+ const file = sessionSlotFile(handle.endpoint);
327
+ const tmp = `${file}.tmp-${process.pid}`;
328
+ const next: SessionSlotRecord = { ...current, muxEndpoint };
329
+ try {
330
+ await writeFile(tmp, JSON.stringify(next), "utf8");
331
+ await rename(tmp, file);
332
+ } catch {
333
+ await unlink(tmp).catch(() => undefined);
334
+ return { advertised: false };
335
+ }
336
+ return { advertised: true };
337
+ }
338
+
247
339
  /**
248
340
  * Release the slot, but ONLY if the record on disk is still the one we wrote.
249
341
  *
package/src/manifest.ts CHANGED
@@ -230,6 +230,7 @@ export const MANIFEST: ToolSpec[] = [
230
230
  },
231
231
  "args": {
232
232
  "type": "array",
233
+ "items": {},
233
234
  "description": "Positional JSON arguments."
234
235
  },
235
236
  "savePath": {
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.1.0";
9
+ export const VERSION = "2.2.0";