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; }