From 46b1284177302811348bdb41b6afcff02b9ffd64 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:57:29 +0200 Subject: [PATCH] Release files when HTTP downloads are canceled (#6567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): close canceled HTTP download streams * test(server): verify canceled downloads release real file descriptors Exercise the reported HTTP cancellation path without replacing shared Node methods, and include download regressions in the standard integration suite. Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com> * test(server): detect canceled download leaks through symlinked paths Compare descriptor targets with the resolved file path and exercise a symlinked download directory so the regression cannot pass by missing the open file. --------- Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- packages/server/src/server/bootstrap.ts | 4 + .../daemon-e2e/file-download.e2e.test.ts | 80 ++++++++++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index 33bd91fd72..ee225cef86 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 43f72dc95f..727c7b7899 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -832,6 +832,9 @@ export async function createPaseoDaemon( try { fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS); const fileStats = await fileHandle.stat(); + if (res.destroyed) { + return; + } if (!fileStats.isFile()) { res.status(404).json({ error: "File not found" }); return; @@ -843,6 +846,7 @@ export async function createPaseoDaemon( const stream = fileHandle.createReadStream(); fileHandle = null; + res.once("close", () => stream.destroy()); stream.on("error", (err) => { logger.error({ err }, "Failed to stream download"); if (!res.headersSent) { diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index ac138a8c01..862923dec6 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -1,5 +1,15 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, rmSync } from "fs"; +import { + mkdtempSync, + writeFileSync, + rmSync, + readdirSync, + readlinkSync, + realpathSync, + symlinkSync, +} from "fs"; +import { once } from "node:events"; +import { get } from "node:http"; import { tmpdir } from "os"; import path from "path"; import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; @@ -8,6 +18,41 @@ function tmpCwd(): string { return mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); } +// Linux exposes the real open descriptors without replacing file or HTTP methods. +function openDownloadDescriptors(filePath: string): string[] { + const realFilePath = realpathSync(filePath); + return readdirSync("/proc/self/fd").filter((fd) => { + try { + return readlinkSync(`/proc/self/fd/${fd}`) === realFilePath; + } catch (error) { + // A descriptor can close between listing it and reading its link. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + }); +} + +async function expectDownloadClosed(filePath: string): Promise { + await expect.poll(() => openDownloadDescriptors(filePath)).toEqual([]); +} + +async function cancelDownloadAfterFirstChunk(url: string): Promise { + const request = get(url); + try { + const [response] = await once(request, "response"); + expect(response.statusCode).toBe(200); + const [chunk] = await once(response, "data"); + expect(chunk.length).toBeGreaterThan(0); + expect(chunk.length).toBeLessThan(64 * 1024 * 1024); + const closed = once(response, "close"); + response.destroy(); + request.destroy(); + await closed; + } finally { + request.destroy(); + } +} + // Use gpt-5.4-mini with low thinking preset for faster test execution const CODEX_TEST_MODEL = "gpt-5.4-mini"; const CODEX_TEST_THINKING_OPTION_ID = "low"; @@ -24,6 +69,39 @@ describe("daemon E2E", () => { }, 60000); describe("file download tokens", () => { + test.skipIf(process.platform !== "linux")( + "releases file descriptors after repeated HTTP download cancellations", + async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "linked-files"); + symlinkSync(mkdtempSync(path.join(directory, "files-")), cwd, "dir"); + const filePath = path.join(cwd, "large.bin"); + const fileSize = 64 * 1024 * 1024; + writeFileSync(filePath, Buffer.alloc(fileSize)); + try { + const completedToken = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(completedToken.error).toBeNull(); + const completed = await fetch( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${completedToken.token}`, + ); + expect(completed.status).toBe(200); + expect((await completed.arrayBuffer()).byteLength).toBe(fileSize); + await expectDownloadClosed(filePath); + + for (let attempt = 0; attempt < 3; attempt++) { + const token = await ctx.client.requestDownloadToken(cwd, "large.bin"); + expect(token.error).toBeNull(); + await cancelDownloadAfterFirstChunk( + `http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${token.token}`, + ); + await expectDownloadClosed(filePath); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, + ); + test.each([ { fileName: "download.txt", disposition: 'attachment; filename="download.txt"' }, {