diff --git a/packages/storage/src/__tests__/session-store.test.ts b/packages/storage/src/__tests__/session-store.test.ts index a2afa7fa00e..40485410d36 100644 --- a/packages/storage/src/__tests__/session-store.test.ts +++ b/packages/storage/src/__tests__/session-store.test.ts @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, open, readFile, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { describe, test } from 'node:test'; @@ -149,6 +149,26 @@ describe('FileSessionStore CRUD', () => { }); }); + test('retries a Windows atomic header replacement while a reader briefly holds the session file open', { + skip: process.platform !== 'win32', + }, async () => { + await withStore(async (store, workspaceRoot) => { + const header = await store.create(makeInput({ name: 'Reader overlap' })); + const sessionPath = join(workspaceRoot, 'sessions', header.id, 'session.jsonl'); + const reader = await open(sessionPath, 'r'); + const releaseReader = setTimeout(() => void reader.close(), 40); + + try { + const updated = await store.updateHeader(header.id, { name: 'Write survived' }); + assert.equal(updated.name, 'Write survived'); + assert.equal((await store.readHeader(header.id)).name, 'Write survived'); + } finally { + clearTimeout(releaseReader); + await reader.close().catch(() => {}); + } + }); + }); + test('remove deletes the session directory entirely', async () => { await withStore(async (store, workspaceRoot) => { const header = await store.create(makeInput({ name: 'Goodbye' })); diff --git a/packages/storage/src/session-store.ts b/packages/storage/src/session-store.ts index 58d3df04389..275dd430c2e 100644 --- a/packages/storage/src/session-store.ts +++ b/packages/storage/src/session-store.ts @@ -1,6 +1,7 @@ import { mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; import { chainWrite } from './write-queue.js'; import { deriveTurnRecords, @@ -364,9 +365,13 @@ class FileSessionStore implements SessionStore { private async writeAtomic(path: string, content: string): Promise { await mkdir(dirname(path), { recursive: true }); - const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`; await writeFile(tempPath, content, 'utf8'); - await rename(tempPath, path); + try { + await replaceFileWithWindowsReaderRetry(tempPath, path); + } finally { + await rm(tempPath, { force: true }).catch(() => {}); + } } private withQueue(sessionId: string, operation: () => Promise): Promise { @@ -375,6 +380,21 @@ class FileSessionStore implements SessionStore { } } +async function replaceFileWithWindowsReaderRetry(tempPath: string, path: string): Promise { + const attempts = process.platform === 'win32' ? 6 : 1; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await rename(tempPath, path); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + const retryable = process.platform === 'win32' && (code === 'EPERM' || code === 'EACCES'); + if (!retryable || attempt === attempts) throw error; + await delay(attempt * 10); + } + } +} + function normalizeStoredMessageForRead(value: unknown): StoredMessage { const message = value as StoredMessage; if (!value || typeof value !== 'object' || Array.isArray(value) || message.type !== 'tool_result') return message;