mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
fix(adapter-utils): tolerate entries removed during workspace snapshots (#18426)
Treat ENOENT from lstat, readlink, content hashing or opendir of a listed non-root entry as an absent entry during workspace snapshot walks. A concurrent restore can rename its temporary merge file between the directory listing and the read. A missing capture root and all other errors still fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c9dd1e444
commit
978ffb6e11
2 files changed
+96
-5
No files matched your search
@@ -183,6 +183,86 @@ describe("workspace restore merge", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("entries removed by a concurrent writer during capture", () => {
|
||||
async function captureWhileRemoving(
|
||||
removeAt: "before-lstat" | "after-lstat",
|
||||
setup: (rootDir: string) => Promise<string>,
|
||||
) {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
await writeFile(path.join(rootDir, "kept.txt"), "kept");
|
||||
const vanishing = await setup(rootDir);
|
||||
const lstatSpy = vi.spyOn(fsPromises, "lstat").mockImplementation(async (target, ...rest) => {
|
||||
if (target !== vanishing) return lstat(target, ...rest);
|
||||
if (removeAt === "before-lstat") await rm(vanishing, { recursive: true, force: true });
|
||||
const stats = await lstat(target, ...rest);
|
||||
await rm(vanishing, { recursive: true, force: true });
|
||||
return stats;
|
||||
});
|
||||
try {
|
||||
return await captureDirectorySnapshot(rootDir, { exclude: [] });
|
||||
} finally { lstatSpy.mockRestore(); }
|
||||
}
|
||||
|
||||
it("omits a listed file that is renamed away before it is read", async () => {
|
||||
const snapshot = await captureWhileRemoving("before-lstat", async (rootDir) => {
|
||||
await mkdir(path.join(rootDir, "tests", "runner-e2e"), { recursive: true });
|
||||
const temporary = path.join(rootDir, "tests", "runner-e2e", ".paperclip-merge-d3bcd290-dd7e-47bf-b2cd-7d2eb1d2b7f2");
|
||||
await writeFile(temporary, "in flight");
|
||||
return temporary;
|
||||
});
|
||||
|
||||
expect(snapshot.entries.get("kept.txt")).toMatchObject({ kind: "file" });
|
||||
expect(snapshot.entries.get("tests/runner-e2e")).toEqual({ kind: "dir" });
|
||||
expect(snapshot.entries.has("tests/runner-e2e/.paperclip-merge-d3bcd290-dd7e-47bf-b2cd-7d2eb1d2b7f2")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits a file that is removed before its contents are hashed", async () => {
|
||||
const snapshot = await captureWhileRemoving("after-lstat", async (rootDir) => {
|
||||
const file = path.join(rootDir, "removed.txt");
|
||||
await writeFile(file, "removed");
|
||||
return file;
|
||||
});
|
||||
|
||||
expect(snapshot.entries.get("kept.txt")).toMatchObject({ kind: "file" });
|
||||
expect(snapshot.entries.has("removed.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("omits the children of a directory that is removed before it is opened", async () => {
|
||||
const snapshot = await captureWhileRemoving("after-lstat", async (rootDir) => {
|
||||
const dir = path.join(rootDir, "removed");
|
||||
await mkdir(dir);
|
||||
await writeFile(path.join(dir, "child.txt"), "child");
|
||||
return dir;
|
||||
});
|
||||
|
||||
expect(snapshot.entries.get("kept.txt")).toMatchObject({ kind: "file" });
|
||||
expect(snapshot.entries.has("removed/child.txt")).toBe(false);
|
||||
});
|
||||
|
||||
it("still fails when the capture root itself is missing", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
await expect(captureDirectorySnapshot(path.join(rootDir, "missing"), { exclude: [] }))
|
||||
.rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
it("still fails on errors other than a vanished entry", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-restore-merge-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const unreadable = path.join(rootDir, "unreadable.txt");
|
||||
await writeFile(unreadable, "secret");
|
||||
const lstatSpy = vi.spyOn(fsPromises, "lstat").mockImplementation(async (target, ...rest) => {
|
||||
if (target === unreadable) throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
|
||||
return lstat(target, ...rest);
|
||||
});
|
||||
try {
|
||||
await expect(captureDirectorySnapshot(rootDir, { exclude: [] })).rejects.toMatchObject({ code: "EACCES" });
|
||||
} finally { lstatSpy.mockRestore(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyWorkspaceRestoreFailure", () => {
|
||||
it.each([
|
||||
"Daytona syncOut refusing tarball with an unparseable entry listing: private listing",
|
||||
|
||||
@@ -144,15 +144,21 @@ async function hashFile(filePath: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
const isVanishedEntry = (error: unknown) => (error as NodeJS.ErrnoException | null)?.code === "ENOENT";
|
||||
|
||||
async function* walkDirectory(
|
||||
root: string, exclude: readonly string[], ignored: ReturnType<typeof workspacePathMatcher>, relative = "",
|
||||
): AsyncGenerator<[string, SnapshotEntry]> {
|
||||
const readEntry = async (nextRelative: string): Promise<[string, SnapshotEntry | null]> => {
|
||||
const fullPath = path.join(root, nextRelative);
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (stats.isDirectory()) return [nextRelative, { kind: "dir" }];
|
||||
if (stats.isSymbolicLink()) return [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
|
||||
if (stats.isFile()) return [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
|
||||
try {
|
||||
const stats = await fs.lstat(fullPath);
|
||||
if (stats.isDirectory()) return [nextRelative, { kind: "dir" }];
|
||||
if (stats.isSymbolicLink()) return [nextRelative, { kind: "symlink", target: await fs.readlink(fullPath) }];
|
||||
if (stats.isFile()) return [nextRelative, { kind: "file", mode: stats.mode, hash: await hashFile(fullPath) }];
|
||||
} catch (error) {
|
||||
if (!isVanishedEntry(error)) throw error;
|
||||
}
|
||||
return [nextRelative, null];
|
||||
};
|
||||
const visitBatch = async function* (paths: string[]): AsyncGenerator<[string, SnapshotEntry]> {
|
||||
@@ -167,8 +173,13 @@ async function* walkDirectory(
|
||||
}
|
||||
};
|
||||
const current = relative ? path.join(root, relative) : root;
|
||||
const dir = await fs.opendir(current).catch((error: unknown) => {
|
||||
if (relative && isVanishedEntry(error)) return null;
|
||||
throw error;
|
||||
});
|
||||
if (!dir) return;
|
||||
let batch: string[] = [];
|
||||
for await (const entry of await fs.opendir(current)) {
|
||||
for await (const entry of dir) {
|
||||
const nextRelative = relative ? path.posix.join(relative, entry.name) : entry.name;
|
||||
if (shouldExcludePath(nextRelative, exclude) || ignored.matches(nextRelative)) continue;
|
||||
batch.push(nextRelative);
|
||||
|
||||
Reference in new issue
Block a user