Release files when HTTP downloads are canceled (#6567)

* 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>
This commit is contained in:
paseo-bot[bot]
2026-10-11 09:57:29 +02:00
committed by GitHub
co-authored by 见安
parent a5dba3c992
commit 46b1284177
3 changed files with 84 additions and 2 deletions
+1 -1
View File
@@ -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",
+4
View File
@@ -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) {
@@ -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<void> {
await expect.poll(() => openDownloadDescriptors(filePath)).toEqual([]);
}
async function cancelDownloadAfterFirstChunk(url: string): Promise<void> {
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"' },
{