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 a7033c4685..100084a80b 100644 --- a/packages/world-local/src/fs.test.ts +++ b/packages/world-local/src/fs.test.ts @@ -19,6 +19,7 @@ import { UnwritableDataDirError } from './build-target-mismatch.js'; import { assertSafeEntityId, clearCreatedFilesCache, + deleteJSON, ensureDir, paginatedFileSystemQuery, readFirstByte, @@ -118,6 +119,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('ensureDir', () => { it('does not repeat mkdir for a directory created by this process', async () => { clearCreatedFilesCache(); diff --git a/packages/world-local/src/fs.ts b/packages/world-local/src/fs.ts index 8ae765d0f0..70178b344c 100644 --- a/packages/world-local/src/fs.ts +++ b/packages/world-local/src/fs.ts @@ -458,7 +458,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; }