From 978ffb6e1106c10aa91ab65068a1472777336d54 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 30 Oct 2026 21:53:19 -0700 Subject: [PATCH] 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 --- .../src/workspace-restore-merge.test.ts | 80 +++++++++++++++++++ .../src/workspace-restore-merge.ts | 21 +++-- 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/packages/adapter-utils/src/workspace-restore-merge.test.ts b/packages/adapter-utils/src/workspace-restore-merge.test.ts index 446a893f027..8cb18108f75 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.test.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.test.ts @@ -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, + ) { + 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", diff --git a/packages/adapter-utils/src/workspace-restore-merge.ts b/packages/adapter-utils/src/workspace-restore-merge.ts index a93574d7429..0fef6cbd02c 100644 --- a/packages/adapter-utils/src/workspace-restore-merge.ts +++ b/packages/adapter-utils/src/workspace-restore-merge.ts @@ -144,15 +144,21 @@ async function hashFile(filePath: string): Promise { }); } +const isVanishedEntry = (error: unknown) => (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; + async function* walkDirectory( root: string, exclude: readonly string[], ignored: ReturnType, 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);