diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b51a4..4326da1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Fix interactive terminal sessions hanging when the upstream never completes the WebSocket handshake: bound the upgrade fetch with the same 10s timeout the runtime adapter fetch in that file already uses. - Stop cancelled Share This Mac cursor reconciliation and release video mailbox waits and their timeout tasks promptly, including cancellation before waiter registration, thanks @SebTardif (#114). ## 0.3.1 - 2026-08-28 diff --git a/src/worker/runtime-adapter-transport.ts b/src/worker/runtime-adapter-transport.ts index 132df3e..1ce677f 100644 --- a/src/worker/runtime-adapter-transport.ts +++ b/src/worker/runtime-adapter-transport.ts @@ -69,7 +69,7 @@ export async function interactiveTerminalFetch( session.adapter === runtimeAdapterName ? runtimeAdapterFetcher(env, target, fallbackFetcher) : fallbackFetcher; - return fetcher.fetch(fetchTarget, { headers }); + return fetcher.fetch(fetchTarget, { headers, signal: AbortSignal.timeout(10_000) }); } export async function readRuntimeAdapterResponseBody(response: Response): Promise { diff --git a/tests/runtime-adapter-transport.test.ts b/tests/runtime-adapter-transport.test.ts index 1e22435..01293bc 100644 --- a/tests/runtime-adapter-transport.test.ts +++ b/tests/runtime-adapter-transport.test.ts @@ -158,3 +158,31 @@ test("runtime adapter response parsing is bounded and preserves non-JSON error t ResponseBodyLimitError, ); }); + +test("terminal upgrades bound the upstream handshake with a timeout", async () => { + const coordinator = recordingFetcher(new Response(null, { status: 200 })); + const fallback = recordingFetcher(new Response(null, { status: 200 })); + const env = { + CRABBOX_COORDINATOR: coordinator.fetcher, + CRABBOX_COORDINATOR_ORIGIN: "https://adapter.example", + } as RuntimeEnv; + const headers = new Headers({ upgrade: "websocket" }); + + await interactiveTerminalFetch( + env, + { adapter: "runtime-v1" }, + "wss://adapter.example/v1/terminal", + headers, + fallback.fetcher, + ); + assert.ok(coordinator.calls[0]?.init?.signal instanceof AbortSignal); + + await interactiveTerminalFetch( + env, + { adapter: null }, + "wss://elsewhere.example/v1/terminal", + headers, + fallback.fetcher, + ); + assert.ok(fallback.calls[0]?.init?.signal instanceof AbortSignal); +});