Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/world-local-windows-unlink-retry.md
Original file line number Diff line number Diff line change
@@ -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.
62 changes: 62 additions & 0 deletions packages/world-local/src/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { UnwritableDataDirError } from './build-target-mismatch.js';
import {
assertSafeEntityId,
clearCreatedFilesCache,
deleteJSON,
ensureDir,
paginatedFileSystemQuery,
readFirstByte,
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion packages/world-local/src/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,12 @@ export async function readFirstByte(

export async function deleteJSON(filePath: string): Promise<void> {
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;
}
Expand Down
Loading