From f0006c77838fe2c2294376c38368669eb90b26f2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 6 Nov 2026 10:45:47 -0800 Subject: [PATCH] fix(workspaces): contain inventory deadline aborts (#19543) A capture deadline can expire while file identity validation awaits the filesystem. Recheck cancellation before creating the inventory read stream so the expired capture rejects without terminating the server. All 224 affected capture and archive tests pass, including real Node subprocess cancellation regressions. Final-head CI passes 54 checks, and Greptile scores 5/5 with no unresolved findings. Co-Authored-By: Paperclip --- doc/execution-semantics.md | 3 + ...emote-workspace-readonly-inventory.test.ts | 72 +++++++++++++++++++ .../remote-workspace-readonly-inventory.ts | 1 + 3 files changed, 76 insertions(+) create mode 100644 server/src/services/remote-workspace-readonly-inventory.test.ts diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 96f0a88b8b4..eda4b250cac 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1054,6 +1054,9 @@ Fallback inventory is limited to 100,000 entries, 64 standalone repositories, 1 MiB of configuration, 1,024 settings and 256 refspec/merge entries. Controller verification and archive normalization share a two-minute cancellation deadline. These limits reject excessive work before publication and keep the source retained. +Inventory rechecks cancellation after file identity validation and before opening +the read stream. An expired deadline rejects the capture without an unhandled +stream error or a successful preservation receipt. Shallow and partial Git checkouts retain their available object files instead of asking a mirror clone to transfer promised or index-only objects. Capture diff --git a/server/src/services/remote-workspace-readonly-inventory.test.ts b/server/src/services/remote-workspace-readonly-inventory.test.ts new file mode 100644 index 00000000000..ccef4f0197d --- /dev/null +++ b/server/src/services/remote-workspace-readonly-inventory.test.ts @@ -0,0 +1,72 @@ +import { execFile } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { readWorkspaceInventory } from "./remote-workspace-readonly-inventory.js"; + +const exec = promisify(execFile); + +describe("workspace inventory cancellation", () => { + let root: string; + + beforeEach(async () => { + root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "workspace-inventory-abort-"))); + await fs.writeFile(path.join(root, "saved.txt"), "preserve this result\n"); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + it.each([ + { controllerCopy: false, timing: "before_stream" }, + { controllerCopy: true, timing: "before_stream" }, + { controllerCopy: false, timing: "during_read" }, + { controllerCopy: true, timing: "during_read" }, + ])("keeps the process alive after cancellation ($timing, controller copy: $controllerCopy)", async ({ controllerCopy, timing }) => { + const source = readWorkspaceInventory.toString().replaceAll("__vite_ssr_dynamic_import__", "import"); + const script = ` + import assert from 'node:assert/strict'; + import fs from 'node:fs/promises'; + import { syncBuiltinESMExports } from 'node:module'; + const __name = value => value; + const inspect = ${source}; + const root = process.argv[1]; + const file = root + '/saved.txt'; + const controller = new AbortController(); + const reason = new DOMException('Capture deadline expired', 'TimeoutError'); + const realpath = fs.realpath; + let armed = false; + fs.realpath = async (...args) => { + const resolved = await realpath(...args); + if (args[0] === file && !armed) { + armed = true; + if (${JSON.stringify(timing)} === 'before_stream') controller.abort(reason); + else setImmediate(() => controller.abort(reason)); + } + return resolved; + }; + syncBuiltinESMExports(); + let rejected = false; + try { + await inspect(root, ${controllerCopy}, controller.signal); + } catch (error) { + assert.ok(error === reason || error.cause === reason); + rejected = true; + } finally { + fs.realpath = realpath; + syncBuiltinESMExports(); + } + assert.equal(rejected, true); + await new Promise(resolve => setImmediate(resolve)); + const saved = await inspect(root, ${controllerCopy}); + assert.equal(await fs.readFile(file, 'utf8'), 'preserve this result\\n'); + process.stdout.write(JSON.stringify(saved)); + `; + const result = await exec(process.execPath, ["--input-type=module", "-e", script, root], { timeout: 5000 }); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ entries: 1, byteSize: 21 }); + }); +}); diff --git a/server/src/services/remote-workspace-readonly-inventory.ts b/server/src/services/remote-workspace-readonly-inventory.ts index 2969e601c02..db418604ba4 100644 --- a/server/src/services/remote-workspace-readonly-inventory.ts +++ b/server/src/services/remote-workspace-readonly-inventory.ts @@ -88,6 +88,7 @@ export async function readWorkspaceInventory(root: string, controllerCopy = fals if (!before.isFile() || !original.isFile() || Number(before.dev) !== stat.dev || Number(before.ino) !== stat.ino || Number(before.size) !== stat.size || Number(before.mode) !== stat.mode || fields.some(key => original[key] !== before[key]) || await fs.realpath(file) !== file) fail(); + check(); for await (const bytes of handle.createReadStream({ autoClose: false, signal })) { check(); size += bytes.length; hash.update(bytes); } const after = await handle.stat({ bigint: true }), current = await fs.lstat(file, { bigint: true }); if (!current.isFile() || size !== Number(before.size) || await fs.realpath(file) !== file