From 55d9f2dbf39b7ba82e5d93aaa50a80c97862fdea Mon Sep 17 00:00:00 2001 From: xiaofeilovecoding Date: Fri, 16 Oct 2026 13:39:23 +0800 Subject: [PATCH] fix(tools): cancel rejected image download streams --- CHANGELOG.md | 2 + backend/cli/src/tool/generate-image.ts | 6 +- .../test/tool/generate-image-download.test.ts | 147 ++++++++++++++++++ 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 backend/cli/test/tool/generate-image-download.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6dcf60608..8db048ea7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ tagged release also ships native binaries for Linux, macOS, and Windows. ## Unreleased +- Cancel image-download response streams before reporting HTTP or unsupported-format errors. + - Stop advertising GPT-5.4 and GPT-5.4 Mini through ChatGPT/Codex subscriptions, whose endpoint rejects those models; they remain available through OpenAI API-key connections. - Model calls now retain provider request IDs across managed, API-key, and ChatGPT subscription routes, and `openscience debug audit ` reports content-free call identities for visible, internal, and worker calls. diff --git a/backend/cli/src/tool/generate-image.ts b/backend/cli/src/tool/generate-image.ts index df3563e12..c340102f7 100644 --- a/backend/cli/src/tool/generate-image.ts +++ b/backend/cli/src/tool/generate-image.ts @@ -253,9 +253,13 @@ async function materializeImage( { signal: AbortSignal.any([signal, AbortSignal.timeout(120_000)]) }, { authorize, maxResponseBytes: MAX_IMAGE_BYTES, streamResponse: true }, ) - if (!response.ok) throw new Error(`The generated image could not be downloaded (${response.status}).`) + if (!response.ok) { + await response.body?.cancel().catch(() => {}) + throw new Error(`The generated image could not be downloaded (${response.status}).`) + } const mime = response.headers.get("content-type")?.split(";")[0]?.trim() ?? "" if (!/^image\/(?:png|jpeg|webp|gif)$/.test(mime)) { + await response.body?.cancel().catch(() => {}) throw new Error(`The generated image URL returned an unsupported format (${mime || "unknown"}).`) } const bytes = await readBoundedImageResponse(response, MAX_IMAGE_BYTES) diff --git a/backend/cli/test/tool/generate-image-download.test.ts b/backend/cli/test/tool/generate-image-download.test.ts new file mode 100644 index 000000000..076171c9f --- /dev/null +++ b/backend/cli/test/tool/generate-image-download.test.ts @@ -0,0 +1,147 @@ +import { expect, spyOn, test } from "bun:test" +import { createServer, request, type IncomingMessage } from "node:http" +import path from "node:path" +import { Readable } from "node:stream" +import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" +import { SessionFilesystem } from "../../src/session/filesystem" +import { Network } from "../../src/settings/network" +import { GenerateImageTool } from "../../src/tool/generate-image" +import { executionSession, tmpdir } from "../fixture/fixture" + +const pixel = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +) + +test.each([ + ["HTTP errors cancel the response", 404, "text/plain", false, "The generated image could not be downloaded (404)."], + [ + "unsupported formats cancel the response", + 200, + "text/plain", + false, + "The generated image URL returned an unsupported format (text/plain).", + ], + ["successful image downloads preserve their bytes", 200, "image/png", false, undefined], + [ + "HTTP errors survive cancellation failures", + 404, + "text/plain", + true, + "The generated image could not be downloaded (404).", + ], + [ + "format errors survive cancellation failures", + 200, + "text/plain", + true, + "The generated image URL returned an unsupported format (text/plain).", + ], +] as const)("%s", async (_name, status, mime, rejects, error) => { + const state = { incoming: undefined as IncomingMessage | undefined, calls: 0, cancellations: 0 } + const controller = new AbortController() + const source = createServer((_request, response) => { + response.writeHead(status, { "content-type": mime }) + if (!error) { + response.end(pixel) + return + } + response.write("pending") + const timer = setInterval(() => response.write("more"), 20) + response.once("close", () => { + clearInterval(timer) + }) + }) + await new Promise((resolve) => source.listen(0, "127.0.0.1", resolve)) + const address = source.address() + if (!address || typeof address === "string") throw new Error("Missing fixture port") + const gateway = Bun.serve({ + port: 0, + fetch() { + state.calls++ + return Response.json({ + choices: [ + { message: { content: [{ type: "text", text: '{"imageUrl":"https://images.example/fixture.png"}' }] } }, + ], + }) + }, + }) + const fetch = Network.fetch + // Keep the actual policy and HTTP-stream bridge; only DNS and the socket destination are fixtures. + const network = spyOn(Network, "fetch").mockImplementation((raw, init, policy) => + fetch(raw, init, { + ...policy, + resolveAddresses: async () => ["93.184.216.34"], + transport: async (_target, options) => + new Promise((resolve, reject) => { + const pending = request( + `http://127.0.0.1:${address.port}/image`, + { signal: options.signal ?? undefined }, + (incoming) => { + state.incoming = incoming + const response = new Response(Readable.toWeb(incoming) as unknown as ReadableStream, { + status: incoming.statusCode, + headers: { "content-type": mime }, + }) + if (rejects) { + spyOn(response.body!, "cancel").mockImplementation(async () => { + state.cancellations++ + throw new Error("Fixture cancellation failed") + }) + } + resolve(response) + }, + ) + pending.once("error", reject) + pending.end() + }), + }), + ) + try { + await using tmp = await tmpdir({ + git: true, + config: { + provider: { + openai: { options: { apiKey: "sk-local-image-fixture", baseURL: `${gateway.url.origin}/v1` } }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => Provider.invalidate(), + fn: async () => { + const session = await executionSession() + const tool = await GenerateImageTool.init() + const pending = tool.execute( + { prompt: "A local fixture", output_path: "fixture.png" }, + { + sessionID: session.id, + messageID: "msg_download_fixture", + callID: "call_download_fixture", + agent: "research", + abort: controller.signal, + messages: [], + metadata() {}, + async ask() {}, + }, + ) + if (error) await expect(pending).rejects.toThrow(error) + if (!error) await pending + expect(state.calls).toBe(1) + expect(state.incoming).toBeDefined() + const output = Bun.file(path.join(await SessionFilesystem.workspace(session.id), "fixture.png")) + if (error && !rejects) expect(state.incoming?.destroyed).toBe(true) + if (rejects) expect(state.cancellations).toBe(1) + if (error) expect(await output.exists()).toBe(false) + if (!error) expect(Buffer.from(await output.arrayBuffer())).toEqual(pixel) + }, + }) + } finally { + controller.abort() + network.mockRestore() + gateway.stop(true) + source.closeAllConnections() + await new Promise((resolve) => source.close(() => resolve())) + } +})