From 0b9b49e5debefbd1c7ddf7db1211e664d33b4f21 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 19:26:40 +0000 Subject: [PATCH] [world-local] Retry transient EPERM unlink failures on Windows (#3215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deleteJSON was the one mutation path in the fs layer that neither used withWindowsRetry nor swallowed unlink errors. On Windows, unlink fails with a share-violation EPERM while a concurrent reader briefly holds the file open — hook polling races deleteAllHooksForRun by design — so a transient EPERM surfaced as a failed operation, e.g. a failed run.cancel(). Wrap the unlink in withWindowsRetry, matching the rename/link/unlink guards the write pipeline already has. ENOENT is not in the retryable set, so the already-deleted tolerance still short-circuits. Signed-off-by: Andrew Barba --- .../world-local-windows-unlink-retry.md | 5 ++ packages/world-local/src/fs.test.ts | 62 +++++++++++++++++++ packages/world-local/src/fs.ts | 7 ++- 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 .changeset/world-local-windows-unlink-retry.md diff --git a/.changeset/world-local-windows-unlink-retry.md b/.changeset/world-local-windows-unlink-retry.md new file mode 100644 index 0000000000..4c44274000 --- /dev/null +++ b/.changeset/world-local-windows-unlink-retry.md @@ -0,0 +1,5 @@ +--- +'@workflow/world-local': patch +--- + +Retry transient `EPERM` unlink failures on Windows when deleting hook and entity JSON files. A concurrent reader briefly holding a file open made `deleteJSON` throw a share-violation `EPERM`, which surfaced as a failed operation — for example a failed `run.cancel()` while `deleteAllHooksForRun` raced hook polling. diff --git a/packages/world-local/src/fs.test.ts b/packages/world-local/src/fs.test.ts index 9670427e19..3ce6389fb8 100644 --- a/packages/world-local/src/fs.test.ts +++ b/packages/world-local/src/fs.test.ts @@ -17,6 +17,7 @@ import { import { z } from 'zod'; import { assertSafeEntityId, + deleteJSON, paginatedFileSystemQuery, readFirstByte, readJSONWithFallback, @@ -115,6 +116,67 @@ describe('fs utilities', () => { }); }); + describe('deleteJSON', () => { + it('deletes the file and tolerates one that is already gone', async () => { + const filePath = path.join(testDir, 'victim.json'); + await fs.writeFile(filePath, '{}'); + + await deleteJSON(filePath); + await expect(fs.access(filePath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + + await expect(deleteJSON(filePath)).resolves.toBeUndefined(); + }); + + it('propagates non-ENOENT unlink failures', async () => { + const eisdir = Object.assign(new Error('EISDIR: illegal operation'), { + code: 'EISDIR', + }); + vi.spyOn(fs, 'unlink').mockRejectedValue(eisdir); + + await expect(deleteJSON(path.join(testDir, 'x.json'))).rejects.toThrow( + 'EISDIR' + ); + }); + + it('retries transient EPERM unlink failures on Windows', async () => { + // On Windows, unlink fails with EPERM while a concurrent reader briefly + // holds the file open (e.g. hook polling racing deleteAllHooksForRun). + // Re-import the module with the platform stubbed so its module-level + // isWindows check takes the retry path. + const platform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + vi.resetModules(); + try { + const freshFsModule = await import('./fs.js'); + const filePath = path.join(testDir, 'locked.json'); + await fs.writeFile(filePath, '{}'); + + const eperm = Object.assign( + new Error('EPERM: operation not permitted, unlink'), + { code: 'EPERM' } + ); + const unlinkSpy = vi + .spyOn(fs, 'unlink') + .mockRejectedValueOnce(eperm) + .mockRejectedValueOnce(eperm); + + await freshFsModule.deleteJSON(filePath); + + expect(unlinkSpy).toHaveBeenCalledTimes(3); + await expect(fs.access(filePath)).rejects.toMatchObject({ + code: 'ENOENT', + }); + } finally { + if (platform) { + Object.defineProperty(process, 'platform', platform); + } + vi.resetModules(); + } + }); + }); + describe('paginatedFileSystemQuery', () => { // Simple getCreatedAt function that strips .json and tries to parse as ULID const getCreatedAt = (filename: string): Date | null => { diff --git a/packages/world-local/src/fs.ts b/packages/world-local/src/fs.ts index 152def44b6..0cd09e8a30 100644 --- a/packages/world-local/src/fs.ts +++ b/packages/world-local/src/fs.ts @@ -403,7 +403,12 @@ export async function readFirstByte( export async function deleteJSON(filePath: string): Promise { try { - await fs.unlink(filePath); + // On Windows, a concurrent reader briefly holding the file open makes + // unlink fail with EPERM (share violation), so retry like the other + // mutation paths in this module. A reader's window is milliseconds; + // without the retry a transient EPERM surfaces as a failed operation + // (e.g. run cancellation via deleteAllHooksForRun). + await withWindowsRetry(() => fs.unlink(filePath)); } catch (error) { if ((error as any).code !== 'ENOENT') throw error; }