From 906d0ad0ffb624ee5337cc639487872a42b79d31 Mon Sep 17 00:00:00 2001 From: Mohammed Alkindi Date: Mon, 7 Sep 2026 13:38:48 +0400 Subject: [PATCH] fix(worker): interactive terminal sessions hang when the upgrade stalls interactiveTerminalFetch issues the WebSocket upgrade handshake with no AbortSignal, so if the upstream never completes the 101 the awaited call never settles and terminal session setup wedges indefinitely. Bound it with the same 10s timeout runtimeAdapterFetch already uses two functions above in the same file. This bounds only the handshake: once the promise resolves with response.webSocket set, a later abort is a no-op against an already-fulfilled promise, so established sessions are unaffected. --- CHANGELOG.md | 1 + src/worker/runtime-adapter-transport.ts | 2 +- tests/runtime-adapter-transport.test.ts | 28 +++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8b51a43..4326da12 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 132df3e8..1ce677f8 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 1e224352..01293bce 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); +});