mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
187c0f6d6e
commit
f0006c7783
3 files changed
+76
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user