From 77f8af769fe939c036690476a4830a41583dcc39 Mon Sep 17 00:00:00 2001 From: Alexander Yue Date: Wed, 29 Jul 2026 20:01:57 -0700 Subject: [PATCH 1/2] fix(browser): retire timed-out snippet sessions and return partial output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser_execute timeout fails the Effect fiber but cannot preempt the snippet's Promise. The orphan kept running against the same Session object the next tool call would receive from SessionStore, letting abandoned code and a fresh call interleave CDP commands on one socket — plausible-but-wrong results, not crashes. Timeouts also discarded all console output. On timeout we now: - retire the exact Session the snippet received (Session.invalidate rejects future connect/_call, closes the socket; in-flight calls are rejected by the existing close handler) and remove it from SessionStore by identity, so a stale caller can never evict a successor Session - freeze the capture buffer (post-timeout console.log and onChunk stop) and return the last 8 KiB of output in the error, cut on a UTF-8 boundary - document the 60s default / 600s max and reconnect-after-timeout in the skill's guardrails Deliberately minimal: no abort plumbing through the connect path (the windows are milliseconds wide and socket close + identity eviction already prevent cross-call interleaving) and no per-session locking (opencode serializes tool calls within an assistant message; concurrent same-session calls have never been observed). Diagnosis and the identity-checked eviction shape come from #118. Tests (no Chrome required) verify: timeout error carries partial output and the retired Session rejects connect/_call while the store hands out a fresh one; capture and onChunk stop after timeout; tail-capping preserves UTF-8. All three fail with the source change reverted. --- .../skills/browser-execute/SKILL.md | 1 + packages/bcode-browser/src/browser-execute.ts | 64 ++++++++++++--- packages/bcode-browser/src/cdp/session.ts | 25 +++++- packages/bcode-browser/src/session-store.ts | 10 +++ .../test/browser-execute.test.ts | 79 +++++++++++++++++++ 5 files changed, 165 insertions(+), 14 deletions(-) diff --git a/packages/bcode-browser/skills/browser-execute/SKILL.md b/packages/bcode-browser/skills/browser-execute/SKILL.md index 217f877ed6..44d8202d0e 100644 --- a/packages/bcode-browser/skills/browser-execute/SKILL.md +++ b/packages/bcode-browser/skills/browser-execute/SKILL.md @@ -197,6 +197,7 @@ console.log(JSON.stringify(titles)) ## Guardrails - Top-level `import` statements inside the snippet body are not allowed. Use `await import(...)` instead. - No CPU-bound infinite loops without `await` — they ignore the timeout. Insert `await new Promise(r => setTimeout(r, 0))` to yield. +- `browser_execute` defaults to 60s (max 600s). For longer work, set the tool's top-level `timeout`; inner CDP timeouts do not extend it. Keep batches small and log progress — timeout errors return recent logs, and a timeout resets the CDP session (reconnect in the next snippet). ## Console - `console.log`, `console.error`, `console.warn`, `console.info`, `console.debug` are all captured and streamed to the user. Treat them as your stdout. Other `console.*` methods write to bcode's stderr without being captured into the tool result. diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index 230bacbd99..02a5d3c472 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -32,10 +32,13 @@ // // Cancellation: JS Promises are not preemptively cancellable. A snippet // without `await` yield-points (e.g. `for (let i = 0; i < 1e9; i++) {}`) -// runs to completion before our timeout fiber observes it. `Effect.timeoutOrElse` -// fails the surrounding fiber but the orphan Promise keeps running until it -// finishes. This matches the `uv run` subprocess case (SIGTERM only after -// the Python signal handler yields). Document, don't fix. +// runs to completion before our timeout fiber observes it. When a yielding +// snippet times out, its Promise keeps running as an orphan — so on timeout +// we retire the exact Session object the snippet received (rejects future +// connect/_call, closes the socket) and evict it from SessionStore. The +// orphan can finish local work but cannot keep driving the browser, and the +// next tool call gets a fresh Session instead of sharing a socket with it. +// The timeout error carries the console output captured so far. // // Level 1 per decisions.md §1c — substantial implementation lives here. The // Level-2 hook in packages/opencode is a thin adapter. @@ -48,6 +51,18 @@ import { Skills } from "./skills" const DEFAULT_TIMEOUT_MS = 60 * 1000 const MAX_TIMEOUT_MS = 10 * 60 * 1000 +const MAX_TIMEOUT_OUTPUT_BYTES = 8 * 1024 +const TIMEOUT_OUTPUT_TRUNCATED = "[partial console output truncated; showing final bytes]\n" + +// Tail-cap the captured output for the timeout error: last 8 KiB, snapped +// forward to a UTF-8 sequence start so multibyte characters survive the cut. +const timeoutOutput = (output: string) => { + const bytes = Buffer.from(output, "utf8") + if (bytes.length <= MAX_TIMEOUT_OUTPUT_BYTES) return output + let start = bytes.length - (MAX_TIMEOUT_OUTPUT_BYTES - Buffer.byteLength(TIMEOUT_OUTPUT_TRUNCATED)) + while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start++ + return TIMEOUT_OUTPUT_TRUNCATED + bytes.subarray(start).toString("utf8") +} // Field order matters: providers stream tool-call args in schema-declared // order, so the model commits to whichever field comes first. `code` is the @@ -157,9 +172,13 @@ const serialize = (v: unknown): string => { export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) { const skillsDir = yield* Effect.promise(() => Skills.resolveSkillsDir(dataDir)) - const execute = (args: Parameters, ctx: ExecuteContext) => - Effect.gen(function* () { - const session = SessionStore.get(ctx.sessionID) + const execute = (args: Parameters, ctx: ExecuteContext) => { + // Resolved outside the generator so the timeout handler below can retire + // the exact Session this snippet received and freeze its capture buffer. + const session = SessionStore.get(ctx.sessionID) + const captured = { active: true, output: "" } + const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS) + return Effect.gen(function* () { yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true })) const wrapped = yield* Effect.try({ @@ -167,10 +186,10 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`), }) - let output = "" const tee = (...a: unknown[]) => { - output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n" - if (ctx.onChunk) Effect.runFork(ctx.onChunk(output)) + if (!captured.active) return + captured.output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n" + if (ctx.onChunk) Effect.runFork(ctx.onChunk(captured.output)) } // Prototype-chain to the real `console` so uncommon methods (`debug`, // `dir`, `trace`, `table`, `group`, …) don't throw when a snippet calls @@ -223,14 +242,33 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`), }).pipe(Effect.ensuring(Effect.sync(() => unsubscribe()))) - return { output, result: serialize(ran), screenshots } satisfies ExecuteResult + return { output: captured.output, result: serialize(ran), screenshots } satisfies ExecuteResult }).pipe( Effect.scoped, Effect.timeoutOrElse({ - duration: Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS), - orElse: () => Effect.fail(new Error("browser_execute timed out")), + duration: timeout, + orElse: () => + Effect.suspend(() => { + captured.active = false + const output = timeoutOutput(captured.output) + const error = new Error( + [ + `browser_execute timed out after ${timeout} ms; CDP session was reset — reconnect in the next snippet`, + output.trim() ? `Partial console output before timeout:\n${output.trimEnd()}` : "", + ] + .filter(Boolean) + .join("\n\n"), + ) + // Identity-checked: only removes the store entry if it still maps + // to this snippet's Session. A concurrent same-sessionID call + // would share the retired object — acceptable for v1, opencode + // serializes tool calls within an assistant message. + SessionStore.invalidate(ctx.sessionID, session, error) + return Effect.fail(error) + }), }), ) + } return { parameters, execute, skillsDir } }) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 3ea5449962..115e9d456d 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -43,6 +43,7 @@ export type DetectedBrowser = { export class Session implements Transport { private ws?: WebSocket; + private invalidatedError?: Error; private nextId = 1; private pending = new Map(); private activeSessionId: string | undefined; @@ -79,6 +80,7 @@ export class Session implements Transport { * and we connect directly to the supplied endpoint. */ async connect(opts: ConnectOptions = {}): Promise { + if (this.invalidatedError) throw this.invalidatedError; const timeoutMs = opts.timeoutMs ?? 5_000; if (opts.wsUrl || opts.profileDir) { const wsUrl = await resolveWsUrl(opts, timeoutMs); @@ -137,13 +139,33 @@ export class Session implements Transport { } isConnected(): boolean { - return this.ws?.readyState === WebSocket.OPEN; + return !this.invalidatedError && this.ws?.readyState === WebSocket.OPEN; } close(): void { this.ws?.close(); } + /** + * Permanently retire this Session object. + * + * `browser_execute` timeouts cannot preempt the snippet's Promise — the + * orphan keeps running and would otherwise share this object (and its + * socket) with the next tool call, interleaving two authors on one + * transport. Invalidation rejects all future `connect`/`_call` attempts + * and closes the socket (the close handler rejects in-flight calls); + * `SessionStore.invalidate` removes the entry so the next call gets a + * fresh Session. + */ + invalidate(error: Error): void { + if (this.invalidatedError) return; + this.invalidatedError = error; + const ws = this.ws; + this.ws = undefined; + this.activeSessionId = undefined; + try { ws?.close(); } catch { /* ignore */ } + } + /** * Pick a target and make subsequent calls auto-route to it. * Uses Target.attachToTarget with flatten:true (single-WS, sessionId-on-message). @@ -239,6 +261,7 @@ export class Session implements Transport { // Transport implementation. Called by the generated domain bindings. _call(method: string, params: unknown = {}): Promise { + if (this.invalidatedError) return Promise.reject(this.invalidatedError); if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return Promise.reject(new Error('Not connected. Call session.connect(...) first.')); } diff --git a/packages/bcode-browser/src/session-store.ts b/packages/bcode-browser/src/session-store.ts index 8be05462c8..87b2e11f68 100644 --- a/packages/bcode-browser/src/session-store.ts +++ b/packages/bcode-browser/src/session-store.ts @@ -27,6 +27,16 @@ export const get = (sessionID: string): Session => { return fresh } +// Retire a specific Session object after a browser_execute timeout. Identity- +// checked so a stale caller can never evict a successor Session that a newer +// call is already using. +export const invalidate = (sessionID: string, expected: Session, error: Error): void => { + const entry = sessions.get(sessionID) + if (entry !== expected) return + sessions.delete(sessionID) + entry.invalidate(error) +} + export const evict = async (sessionID: string): Promise => { const entry = sessions.get(sessionID) if (!entry) return diff --git a/packages/bcode-browser/test/browser-execute.test.ts b/packages/bcode-browser/test/browser-execute.test.ts index 5b1ca9eb49..bd0adc6f8f 100644 --- a/packages/bcode-browser/test/browser-execute.test.ts +++ b/packages/bcode-browser/test/browser-execute.test.ts @@ -224,6 +224,85 @@ test("console.debug is captured; uncommon methods fall through without throwing" await Promise.all([data, ws].map((d) => fs.rm(d, { recursive: true, force: true }))) }) +// Timeout isolation: a timed-out snippet keeps running as an orphan (JS +// Promises are not preemptible), so the tool must retire the Session object +// the snippet received and surface captured output in the error. No Chrome +// required — the snippets sleep without touching the browser. +const runTimeout = async (id: string, code: string, timeout: number, onChunk?: (o: string) => Effect.Effect) => { + const data = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-to-")) + const ws = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-to-ws-")) + const err = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const impl = yield* BrowserExecute.make(data) + return yield* impl.execute( + { description: "timeout test", code, timeout }, + { sessionID: id, workspaceDir: ws, onChunk }, + ) + }), + ), + ).then( + () => { throw new Error("expected timeout") }, + (e: unknown) => String(e), + ) + await Promise.all([data, ws].map((d) => fs.rm(d, { recursive: true, force: true }))) + return err +} + +test("timeout returns partial output and retires the session", async () => { + const id = "timeout-isolation-test" + const before = SessionStore.get(id) + const err = await runTimeout( + id, + `console.log("progress-marker"); + await new Promise((r) => setTimeout(r, 60_000));`, + 100, + ) + expect(err).toContain("timed out after 100 ms") + expect(err).toContain("Partial console output before timeout:") + expect(err).toContain("progress-marker") + // The orphan's Session is permanently dead... + expect(before.isConnected()).toBe(false) + await expect(before.connect({ wsUrl: "ws://127.0.0.1:9/nope" })).rejects.toThrow(/timed out after 100 ms/) + await expect(before.domains.Runtime.evaluate({ expression: "1" })).rejects.toThrow(/timed out after 100 ms/) + // ...and the next tool call gets a fresh one. + expect(SessionStore.get(id)).not.toBe(before) + await SessionStore.evict(id) +}) + +test("console capture and onChunk stop after timeout", async () => { + const chunks: string[] = [] + const err = await runTimeout( + "timeout-capture-test", + `console.log("early"); + await new Promise((r) => setTimeout(r, 250)); + console.log("late");`, + 100, + (o) => Effect.sync(() => { chunks.push(o) }), + ) + expect(err).toContain("early") + // Let the orphan's late log fire, then confirm it was not captured. + await new Promise((r) => setTimeout(r, 400)) + expect(chunks.some((c) => c.includes("early"))).toBe(true) + expect(chunks.some((c) => c.includes("late"))).toBe(false) + await SessionStore.evict("timeout-capture-test") +}) + +test("timeout output is tail-capped to valid UTF-8", async () => { + // ~25 KiB of multibyte lines, all logged before the sleep. + const err = await runTimeout( + "timeout-truncate-test", + `for (let i = 0; i < 300; i++) console.log("é".repeat(40) + "-line-" + i); + await new Promise((r) => setTimeout(r, 60_000));`, + 100, + ) + expect(err).toContain("[partial console output truncated; showing final bytes]") + expect(err).toContain("-line-299") + expect(err).not.toContain("-line-0\n") + expect(err).not.toContain("\uFFFD") + await SessionStore.evict("timeout-truncate-test") +}) + // Concurrency safety: two overlapping execute() calls (different sessionIDs) // must each capture their own console output without leaking into each other // or into the real global console. No Chrome required — the snippets never From b1f6512be4852fdffdf1c00342e1797f086e2baa Mon Sep 17 00:00:00 2001 From: Alexander Yue Date: Wed, 29 Jul 2026 20:41:25 -0700 Subject: [PATCH 2/2] fix(browser): harden session retirement per review Address cubic review on #126: - openWs re-checks invalidation (entry, open, close paths) so a connect awaiting resolver/detection steps cannot open a late socket for a retired Session; the detect-loop rethrows instead of aggregating the retirement error. In-flight and pre-open calls now reject with the retirement error rather than the generic socket-closed message. - SessionStore.invalidate always retires the expected Session; the identity check now only guards the map delete. Previously a replaced entry left the stale object un-retired. - execute() state (Session, capture buffer, timeout) moved inside Effect.suspend: Effect values are re-runnable, and a re-run after a timeout must not inherit a retired Session or frozen capture. Declined (rationale on the PR): interrupting in-flight onChunk fibers (late delivery carries output that was true at timeout; final result supersedes) and rolling-tail capping of the capture buffer (success path returns full output by contract; duration is bounded by the 600s cap). Four new tests; the connect-entry, replaced-entry, and re-run pins fail against the previous commit. --- packages/bcode-browser/src/browser-execute.ts | 20 +++++---- packages/bcode-browser/src/cdp/session.ts | 11 +++-- packages/bcode-browser/src/session-store.ts | 13 +++--- .../test/browser-execute.test.ts | 45 +++++++++++++++++++ .../bcode-browser/test/cdp-session.test.ts | 27 +++++++++++ 5 files changed, 98 insertions(+), 18 deletions(-) diff --git a/packages/bcode-browser/src/browser-execute.ts b/packages/bcode-browser/src/browser-execute.ts index 02a5d3c472..ddf756ef93 100644 --- a/packages/bcode-browser/src/browser-execute.ts +++ b/packages/bcode-browser/src/browser-execute.ts @@ -172,9 +172,12 @@ const serialize = (v: unknown): string => { export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) { const skillsDir = yield* Effect.promise(() => Skills.resolveSkillsDir(dataDir)) - const execute = (args: Parameters, ctx: ExecuteContext) => { - // Resolved outside the generator so the timeout handler below can retire - // the exact Session this snippet received and freeze its capture buffer. + // Effect values are re-runnable, so per-run state lives inside the suspend + // thunk: each run resolves its own Session (the timeout handler retires + // exactly that object) and its own capture buffer (a re-run after a timeout + // must not inherit a retired Session or a frozen capture). + const execute = (args: Parameters, ctx: ExecuteContext) => + Effect.suspend(() => { const session = SessionStore.get(ctx.sessionID) const captured = { active: true, output: "" } const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS) @@ -259,16 +262,17 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) .filter(Boolean) .join("\n\n"), ) - // Identity-checked: only removes the store entry if it still maps - // to this snippet's Session. A concurrent same-sessionID call - // would share the retired object — acceptable for v1, opencode - // serializes tool calls within an assistant message. + // Always retires this snippet's Session; the identity check + // inside only guards the store delete, so a successor Session is + // never evicted. A concurrent same-sessionID call would share the + // retired object — acceptable for v1, opencode serializes tool + // calls within an assistant message. SessionStore.invalidate(ctx.sessionID, session, error) return Effect.fail(error) }), }), ) - } + }) return { parameters, execute, skillsDir } }) diff --git a/packages/bcode-browser/src/cdp/session.ts b/packages/bcode-browser/src/cdp/session.ts index 115e9d456d..b19713c5ef 100644 --- a/packages/bcode-browser/src/cdp/session.ts +++ b/packages/bcode-browser/src/cdp/session.ts @@ -105,6 +105,7 @@ export class Session implements Transport { await this.openWs(b.wsUrl, timeoutMs); return; } catch (e) { + if (this.invalidatedError) throw this.invalidatedError; const msg = e instanceof Error ? e.message : String(e); errors.push(` ${b.name} @ ${b.wsUrl}: ${msg}`); } @@ -115,6 +116,10 @@ export class Session implements Transport { } private openWs(wsUrl: string, timeoutMs: number): Promise { + // Re-checked here (not only in connect) because connect awaits resolver/ + // detection steps first — an invalidation landing during those must not + // open a late socket for a retired Session. + if (this.invalidatedError) return Promise.reject(this.invalidatedError); return new Promise((res, rej) => { const ws = new WebSocket(wsUrl); let done = false; @@ -126,13 +131,13 @@ export class Session implements Transport { else res(); }; const timer = setTimeout(() => finish(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs); - ws.addEventListener('open', () => finish()); + ws.addEventListener('open', () => finish(this.invalidatedError)); ws.addEventListener('error', (e) => finish(new Error(`WS error: ${(e as any)?.message ?? 'connect failed (likely 403, permission not granted, or port closed)'}`))); ws.addEventListener('message', (e) => this.onMessage(String(e.data))); ws.addEventListener('close', () => { - for (const [, p] of this.pending) p.reject(new Error('CDP socket closed')); + for (const [, p] of this.pending) p.reject(this.invalidatedError ?? new Error('CDP socket closed')); this.pending.clear(); - finish(new Error('WS closed before open (likely 403 or port closed)')); + finish(this.invalidatedError ?? new Error('WS closed before open (likely 403 or port closed)')); }); this.ws = ws; }); diff --git a/packages/bcode-browser/src/session-store.ts b/packages/bcode-browser/src/session-store.ts index 87b2e11f68..4d911a5faa 100644 --- a/packages/bcode-browser/src/session-store.ts +++ b/packages/bcode-browser/src/session-store.ts @@ -27,14 +27,13 @@ export const get = (sessionID: string): Session => { return fresh } -// Retire a specific Session object after a browser_execute timeout. Identity- -// checked so a stale caller can never evict a successor Session that a newer -// call is already using. +// Retire a specific Session object after a browser_execute timeout. The +// expected Session is always invalidated; the identity check only guards the +// map delete, so a stale caller can never evict a successor Session that a +// newer call is already using. export const invalidate = (sessionID: string, expected: Session, error: Error): void => { - const entry = sessions.get(sessionID) - if (entry !== expected) return - sessions.delete(sessionID) - entry.invalidate(error) + if (sessions.get(sessionID) === expected) sessions.delete(sessionID) + expected.invalidate(error) } export const evict = async (sessionID: string): Promise => { diff --git a/packages/bcode-browser/test/browser-execute.test.ts b/packages/bcode-browser/test/browser-execute.test.ts index bd0adc6f8f..5c62b65e02 100644 --- a/packages/bcode-browser/test/browser-execute.test.ts +++ b/packages/bcode-browser/test/browser-execute.test.ts @@ -288,6 +288,51 @@ test("console capture and onChunk stop after timeout", async () => { await SessionStore.evict("timeout-capture-test") }) +test("re-running the execute effect after a timeout gets fresh state", async () => { + const data = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-rerun-")) + const ws = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-rerun-ws-")) + const impl = await Effect.runPromise(BrowserExecute.make(data)) + // One Effect value, run twice. Each run must resolve its own Session and + // capture buffer — the second run's error must carry its own partial + // output, not inherit the first run's frozen capture or retired Session. + // onChunk deliveries discriminate: a run that inherited a frozen capture + // buffer never tees, so it produces zero chunks (the frozen buffer still + // *contains* run 1's text, which is why asserting on the error message + // alone cannot catch this). + const chunks: string[] = [] + const eff = impl.execute( + { + description: "rerun test", + code: `console.log("progress-marker"); + await new Promise((r) => setTimeout(r, 60_000));`, + timeout: 100, + }, + { sessionID: "rerun-test", workspaceDir: ws, onChunk: (o) => Effect.sync(() => { chunks.push(o) }) }, + ) + const run = () => Effect.runPromise(eff).then(() => "resolved", (e: unknown) => String(e)) + const first = await run() + const afterFirst = chunks.length + const second = await run() + expect(first).toContain("progress-marker") + expect(second).toContain("progress-marker") + expect(afterFirst).toBeGreaterThan(0) + expect(chunks.length).toBeGreaterThan(afterFirst) + await SessionStore.evict("rerun-test") + await Promise.all([data, ws].map((d) => fs.rm(d, { recursive: true, force: true }))) +}) + +test("invalidate retires the expected Session even after replacement", async () => { + const id = "invalidate-replaced-test" + const s1 = SessionStore.get(id) + await SessionStore.evict(id) + const s2 = SessionStore.get(id) + SessionStore.invalidate(id, s1, new Error("retired stale session")) + // The successor entry is untouched, but the stale object is still dead. + expect(SessionStore.get(id)).toBe(s2) + await expect(s1.connect({ wsUrl: "ws://127.0.0.1:9/nope" })).rejects.toThrow(/retired stale session/) + await SessionStore.evict(id) +}) + test("timeout output is tail-capped to valid UTF-8", async () => { // ~25 KiB of multibyte lines, all logged before the sleep. const err = await runTimeout( diff --git a/packages/bcode-browser/test/cdp-session.test.ts b/packages/bcode-browser/test/cdp-session.test.ts index 722627a7c7..c5b916b4ee 100644 --- a/packages/bcode-browser/test/cdp-session.test.ts +++ b/packages/bcode-browser/test/cdp-session.test.ts @@ -77,3 +77,30 @@ test("waitFor throws on a positional timeout rather than silently using the 30s await expect(session.waitFor("Test.never", { timeoutMs: 50 })).rejects.toThrow(/Timeout waiting for/) expect(Date.now() - started).toBeLessThan(1_000) }) + +// Retirement guarantee under in-flight connects: an invalidation landing +// while connect() is between awaits must not leave a usable or open socket. +test("invalidate before the socket exists rejects the in-flight connect", async () => { + const s = new Session() + // connect() awaits resolveWsUrl before openWs, so invalidate() runs while + // no socket exists yet — openWs must refuse to create one afterwards. + const connecting = s.connect({ wsUrl: `ws://127.0.0.1:${server.port}/`, timeoutMs: 1_000 }) + s.invalidate(new Error("retired by test")) + await expect(connecting).rejects.toThrow("retired by test") + expect(s.isConnected()).toBe(false) +}) + +test("invalidate while the socket is connecting closes it and rejects", async () => { + const s = new Session() + const connecting = s.connect({ wsUrl: `ws://127.0.0.1:${server.port}/`, timeoutMs: 1_000 }) + // Yield one macrotask so openWs has created the WebSocket, then retire. + await Bun.sleep(0) + s.invalidate(new Error("retired by test")) + // Depending on whether the open event won the race, connect either rejects + // or resolved just before retirement — in both cases the Session must end + // dead with no usable transport. + await connecting.catch(() => {}) + expect(s.isConnected()).toBe(false) + await expect(s._call("Runtime.evaluate", { expression: "1" })).rejects.toThrow("retired by test") + await expect(s.connect({ wsUrl: `ws://127.0.0.1:${server.port}/` })).rejects.toThrow("retired by test") +})