/** * A short-lived, cross-process mutex for genuinely global browser resources. * * Most of this bridge is per-conversation: each binding has its own tab, its own * CDP session, its own conversation lock. That is enough for driving two ChatGPT * conversations at once. * * One thing is not per-conversation: the operating system clipboard. Reading it is * how a copied plan is retrieved, and "click the copy button, then read the * clipboard" is a read-modify-write on a single shared resource. Two threads * interleaving there look like this: * * A clicks its copy button * B clicks its copy button * A reads the clipboard -> gets B's plan * * The per-source authorship check in the page adapter catches that, because the * text will not match what A expected, and A falls back to reading the DOM. So the * failure mode is degradation, not corruption -- which is why this mutex may wait * and then proceed rather than fail the round: the correctness net is already * underneath it. * * `Page.bringToFront` is in the same category for a different reason: it is a * focus action on the whole browser, so two threads doing it at once fight over * which tab the user sees. Not a correctness problem, but it belongs inside the * same critical section as the clipboard read that follows it. * * The lock is a file, so it works between the pi extension and the external cycle * driver, which are separate processes. */ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { browserMutexDir } from "./paths.ts"; // The mutex protects the real clipboard and focus, so it is resource-coordination class: // it never redirects under an isolated state root. A test with an isolated mutex would be // invisible to production while driving the same physical resources -- the race itself. export function defaultMutexDir(): string { return browserMutexDir(); } export interface MutexRecord { owner: string; pid: number; acquiredAt: number; /** What the holder is doing, for diagnostics. */ purpose: string; } /** * A14: ONE lock-staleness semantic for every lock file in the system (browser * mutex, registry write lock, conversation driver locks). A record is stale when * its TTL passed OR its pid is gone (EPERM means alive but inaccessible). * Everything that takes over a stale lock shares this rule, so an inventory * scanner and the take-over logic cannot disagree about what "stale" means. */ export function isLockRecordStale( record: { pid: number; acquiredAt: number }, now: number, ttlMs: number, ): boolean { if (now - record.acquiredAt > ttlMs) return true; if (!Number.isInteger(record.pid) || record.pid <= 0) return true; try { process.kill(record.pid, 0); return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM" ? false : true; } } /** A crashed holder must not wedge the mutex forever. */ export const DEFAULT_MUTEX_TTL_MS = 20_000; export interface MutexRunOptions { /** Give up waiting after this long and proceed, reporting it. */ waitMs?: number; purpose?: string; onWait?: (info: { waitedMs: number; holder: MutexRecord | null }) => void; onProceedWithoutLock?: (info: { waitedMs: number; holder: MutexRecord | null }) => void; now?: () => number; } export class BrowserMutex { readonly path: string; private readonly ttlMs: number; private readonly owner: string; constructor(options: { dir?: string; ttlMs?: number; owner?: string } = {}) { this.path = join(options.dir ?? defaultMutexDir(), "browser.lock"); this.ttlMs = options.ttlMs ?? DEFAULT_MUTEX_TTL_MS; this.owner = options.owner ?? `pid-${process.pid}`; } peek(): MutexRecord | null { try { if (!existsSync(this.path)) return null; return JSON.parse(readFileSync(this.path, "utf8")) as MutexRecord; } catch { return null; } } private isStale(record: MutexRecord, now: number): boolean { return isLockRecordStale(record, now, this.ttlMs); } private tryAcquire(purpose: string, now: number): { ok: boolean; holder: MutexRecord | null } { const existing = this.peek(); if (existing && existing.owner !== this.owner && !this.isStale(existing, now)) { return { ok: false, holder: existing }; } mkdirSync(dirname(this.path), { recursive: true }); const temp = `${this.path}.tmp-${process.pid}`; writeFileSync( temp, `${JSON.stringify({ owner: this.owner, pid: process.pid, acquiredAt: now, purpose }, null, 2)}\n`, "utf8", ); renameSync(temp, this.path); return { ok: true, holder: existing }; } private release(): void { try { const existing = this.peek(); // Only the holder may remove it, and only if it is still ours: a lock that // expired and was taken over by someone else must not be deleted by us. if (existing && existing.owner !== this.owner) return; if (existsSync(this.path)) unlinkSync(this.path); } catch { // best effort } } /** * Run `fn` inside the critical section. * * Waits for the lock, and if the wait runs out, runs anyway and says so. The * alternative -- failing the operation -- would turn a clipboard race into a * failed round, and the page adapter's authorship check already prevents the * race from producing wrong content. */ async run(fn: () => Promise, options: MutexRunOptions = {}): Promise { const now = options.now ?? (() => Date.now()); const waitMs = options.waitMs ?? 30_000; const startedAt = now(); let holder: MutexRecord | null = null; let acquired = false; for (;;) { const attempt = this.tryAcquire(options.purpose ?? "browser", now()); if (attempt.ok) { acquired = true; break; } holder = attempt.holder; const waited = now() - startedAt; if (waited >= waitMs) break; options.onWait?.({ waitedMs: waited, holder }); await new Promise((resolve) => setTimeout(resolve, 150)); } if (!acquired) { options.onProceedWithoutLock?.({ waitedMs: now() - startedAt, holder }); } try { return await fn(); } finally { if (acquired) this.release(); } } }