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:
Devin FoleyandClaude Opus 5.5 authored and GitHub committed 2026-10-30 21:53:19 -07:00
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);