From be7f19135aa90909b05fd643166a638855b94fe7 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 11 Nov 2026 17:14:44 +0800 Subject: [PATCH 1/2] fix(memory): retry atomic writes under Windows file contention Atomic workspace replacement used a single rename, so a temporary reader lock aborted checkpoint publication. Reuse the bounded Windows retry and preserve the original file/error when the lock persists. Real reader-handle tests cover both outcomes; 5,207 tests pass with 4 skipped and typecheck passes in the equivalent ASCII-path worktree. --- bots/cormini/persona/memory.ts | 3 +- tests/cormini/memory-busy.test.ts | 46 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/cormini/memory-busy.test.ts diff --git a/bots/cormini/persona/memory.ts b/bots/cormini/persona/memory.ts index d8db975ed..d4ea7a62a 100644 --- a/bots/cormini/persona/memory.ts +++ b/bots/cormini/persona/memory.ts @@ -8,6 +8,7 @@ import { } from 'node:fs'; import { basename, dirname, isAbsolute, join, normalize as pathNormalize, relative, resolve, sep } from 'node:path'; import { MEM_SCHEME, mimeOfHandle } from 'cortico/core/blobs.ts'; +import { retryBusy } from 'cortico/core/fs-busy.ts'; import type { BlobStore } from 'cortico/core/types.ts'; import { SYSTEM_TIMEZONE, decodeText, isUtf16Le, nowIso, readTextFile } from 'cortico/core/util.ts'; import { WorkspaceGit } from './workspaceGit.ts'; @@ -368,7 +369,7 @@ export class GitWorkspaceMemory { const tmp = `${abs}.tmp-${Math.random().toString(36).slice(2, 8)}`; try { writeFileSync(tmp, content, 'utf8'); - renameSync(tmp, abs); + retryBusy(() => renameSync(tmp, abs)); } catch (error) { try { if (existsSync(tmp)) unlinkSync(tmp); diff --git a/tests/cormini/memory-busy.test.ts b/tests/cormini/memory-busy.test.ts new file mode 100644 index 000000000..af27d7ee0 --- /dev/null +++ b/tests/cormini/memory-busy.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { GitWorkspaceMemory } from '../../bots/cormini/persona/memory.ts'; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +async function lockedFile(holdMs: number) { + const root = mkdtempSync(join(tmpdir(), 'memory-atomic-busy-')); roots.push(root); + const file = join(root, 'checkpoint.json'), ready = join(root, 'ready'); + writeFileSync(file, '{"revision":1}'); + // A separate process holds a real Windows handle without FILE_SHARE_DELETE. + // It can release the handle while the synchronous writer waits. + const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', + '$stream=[IO.File]::Open($env:ATOMIC_TEST_FILE,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); try { [IO.File]::WriteAllText($env:ATOMIC_TEST_READY,"ready"); Start-Sleep -Milliseconds ([int]$env:ATOMIC_TEST_HOLD) } finally { $stream.Dispose() }'], + { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, ATOMIC_TEST_FILE: file, ATOMIC_TEST_READY: ready, ATOMIC_TEST_HOLD: String(holdMs) } }); + let error = ''; child.stderr.on('data', chunk => { error += chunk; }); + const exited = new Promise((resolve, reject) => { child.on('exit', resolve); child.on('error', reject); }); + const deadline = Date.now() + 5000; + while (!existsSync(ready) && child.exitCode === null && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); + if (!existsSync(ready)) { child.kill(); await exited; throw Error('Lock helper not ready: ' + error); } + return { root, file, exited, memory: new GitWorkspaceMemory({ memoryDir: root }) }; +} + +describe.skipIf(process.platform !== 'win32')('atomic workspace publication under Windows file locks', () => { + it('publishes the completed checkpoint after a temporary reader releases it', async () => { + const fixture = await lockedFile(220); + try { + fixture.memory.writeFileAtomic('checkpoint.json', '{"revision":2,"receipt":"native-result"}'); + expect(JSON.parse(readFileSync(fixture.file, 'utf8'))).toEqual({ revision: 2, receipt: 'native-result' }); + expect(readdirSync(fixture.root).filter(name => name.includes('.tmp-'))).toEqual([]); + } finally { await fixture.exited; } + }, 10000); + + it('keeps the previous checkpoint and reports failure when the lock outlasts the bounded retry', async () => { + const fixture = await lockedFile(3800); + try { + expect(() => fixture.memory.writeFileAtomic('checkpoint.json', '{"revision":2}')).toThrow(); + expect(JSON.parse(readFileSync(fixture.file, 'utf8'))).toEqual({ revision: 1 }); + expect(readdirSync(fixture.root).filter(name => name.includes('.tmp-'))).toEqual([]); + } finally { await fixture.exited; } + }, 10000); +}); From a4141a0944ba64f2a010a5a60d598c874ac551cd Mon Sep 17 00:00:00 2001 From: Phant Date: Thu, 12 Nov 2026 12:12:53 -0600 Subject: [PATCH 2/2] =?UTF-8?q?test(bots/cormini):=20=E5=88=A0=E6=8E=89?= =?UTF-8?q?=E7=94=A8=E7=9C=9F=E5=AD=90=E8=BF=9B=E7=A8=8B=E5=8D=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=20memory-busy=20=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 两个用例靠 PowerShell 子进程占住目标文件,第二个靠 3800 ms 占用和 retryBusy 约 2970 ms 退避之间的余量成立,慢的 runner 上子进程起得晚就会翻红。 - 其余包了 retryBusy 的改名(state、timers、extensions、context-log)都没有按调用点 测占用。 Co-Authored-By: Claude Fable 5.2 --- tests/cormini/memory-busy.test.ts | 46 ------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 tests/cormini/memory-busy.test.ts diff --git a/tests/cormini/memory-busy.test.ts b/tests/cormini/memory-busy.test.ts deleted file mode 100644 index af27d7ee0..000000000 --- a/tests/cormini/memory-busy.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { spawn } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { GitWorkspaceMemory } from '../../bots/cormini/persona/memory.ts'; - -const roots: string[] = []; -afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); - -async function lockedFile(holdMs: number) { - const root = mkdtempSync(join(tmpdir(), 'memory-atomic-busy-')); roots.push(root); - const file = join(root, 'checkpoint.json'), ready = join(root, 'ready'); - writeFileSync(file, '{"revision":1}'); - // A separate process holds a real Windows handle without FILE_SHARE_DELETE. - // It can release the handle while the synchronous writer waits. - const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', - '$stream=[IO.File]::Open($env:ATOMIC_TEST_FILE,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite); try { [IO.File]::WriteAllText($env:ATOMIC_TEST_READY,"ready"); Start-Sleep -Milliseconds ([int]$env:ATOMIC_TEST_HOLD) } finally { $stream.Dispose() }'], - { windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'], env: { ...process.env, ATOMIC_TEST_FILE: file, ATOMIC_TEST_READY: ready, ATOMIC_TEST_HOLD: String(holdMs) } }); - let error = ''; child.stderr.on('data', chunk => { error += chunk; }); - const exited = new Promise((resolve, reject) => { child.on('exit', resolve); child.on('error', reject); }); - const deadline = Date.now() + 5000; - while (!existsSync(ready) && child.exitCode === null && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10)); - if (!existsSync(ready)) { child.kill(); await exited; throw Error('Lock helper not ready: ' + error); } - return { root, file, exited, memory: new GitWorkspaceMemory({ memoryDir: root }) }; -} - -describe.skipIf(process.platform !== 'win32')('atomic workspace publication under Windows file locks', () => { - it('publishes the completed checkpoint after a temporary reader releases it', async () => { - const fixture = await lockedFile(220); - try { - fixture.memory.writeFileAtomic('checkpoint.json', '{"revision":2,"receipt":"native-result"}'); - expect(JSON.parse(readFileSync(fixture.file, 'utf8'))).toEqual({ revision: 2, receipt: 'native-result' }); - expect(readdirSync(fixture.root).filter(name => name.includes('.tmp-'))).toEqual([]); - } finally { await fixture.exited; } - }, 10000); - - it('keeps the previous checkpoint and reports failure when the lock outlasts the bounded retry', async () => { - const fixture = await lockedFile(3800); - try { - expect(() => fixture.memory.writeFileAtomic('checkpoint.json', '{"revision":2}')).toThrow(); - expect(JSON.parse(readFileSync(fixture.file, 'utf8'))).toEqual({ revision: 1 }); - expect(readdirSync(fixture.root).filter(name => name.includes('.tmp-'))).toEqual([]); - } finally { await fixture.exited; } - }, 10000); -});