From db847a98ee1d6428ffd491c2916713caa2665042 Mon Sep 17 00:00:00 2001 From: Aniruddha Adak Date: Sun, 4 Oct 2026 14:25:39 +0530 Subject: [PATCH] fix(server): release a refused download's body before reporting it A download was refused when the computer's response carried no usable content-length, but the body was left unread: the throw happened with the stream still checked out. Nothing else ever reads it, and it can be as large as the whole workspace download budget. A computer reached through a proxy that re-chunks answers 200 with Transfer-Encoding and no content-length, so every attempt left a transfer running and a connection held until it was collected. A Bot retrying a download turned one refusal into a slow leak rather than one clear error. The refusal branch above already consumed its body via response.json(); this brings the remaining refusal in line, and matches the release idiom already used in provider-oauth.ts and voice/. A cancel that fails must not replace the refusal the caller is told about, so it is guarded and the ComputerUnavailableError still surfaces. --- CHANGELOG.md | 5 ++++ server/src/computer/client.ts | 4 ++++ server/tests/computer-client.test.ts | 34 ++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb13829c..387997550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +- A file download refused because the computer's response carried no usable byte length now releases + the connection before reporting the refusal. The unread body could be as large as the whole + download budget, so a computer reached through a proxy that re-chunks left a transfer running and a + connection checked out of the pool on every attempt. + ## 0.1.0 **Before upgrading.** Six things change for an existing deployment: diff --git a/server/src/computer/client.ts b/server/src/computer/client.ts index 3fb61ec4e..74171d434 100644 --- a/server/src/computer/client.ts +++ b/server/src/computer/client.ts @@ -293,6 +293,10 @@ export function createComputerTransport( const bytes = length !== null && /^\d+$/.test(length) ? Number(length) : Number.NaN; if (!response.body || !Number.isSafeInteger(bytes) || bytes < 0) { + // Nothing else will ever read this body, so release it here rather than + // leaving the whole download budget checked out of the pool. A cancel that + // fails must not replace the refusal the caller is told about. + await response.body?.cancel().catch(() => undefined); throw new ComputerUnavailableError( "The assistant's computer returned an invalid file download.", ); diff --git a/server/tests/computer-client.test.ts b/server/tests/computer-client.test.ts index 5e57f461e..7cb4fa2c2 100644 --- a/server/tests/computer-client.test.ts +++ b/server/tests/computer-client.test.ts @@ -73,6 +73,40 @@ describe("computer client", () => { ); }); + test("releases the connection it refuses to hand back", async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2, 3])); + }, + cancel() { + cancelled = true; + }, + }); + const client = clientWith(() => new Response(body)); + + await expect(client.download("x")).rejects.toThrow( + ComputerUnavailableError, + ); + expect(cancelled).toBe(true); + }); + + test("still names the invalid download when releasing it fails", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.from([1, 2, 3])); + }, + cancel() { + throw new Error("socket already gone"); + }, + }); + const client = clientWith(() => new Response(body)); + + await expect(client.download("x")).rejects.toThrow( + ComputerUnavailableError, + ); + }); + test.each([ [404, WorkspaceNotFoundError], [413, WorkspaceTooLargeError],