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