Skip to content

Commit 914e0f3

Browse files
committed
Add fsync-durability and Windows rename-retry coverage to atomic writes
Companion regression tests for the fsync-before-rename durability fix: the fsync must precede the rename (power loss between them leaves the old file intact), and the async rename retries EPERM/EBUSY/EACCES with bounded backoff while rethrowing anything else on the first attempt. Recovery loader, backup rotation, and UI notice split out to a follow-up PR per review.
1 parent e9d48bc commit 914e0f3

5 files changed

Lines changed: 88 additions & 309 deletions

File tree

‎cli/src/hooks/use-send-message.ts‎

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -187,21 +187,7 @@ export const useSendMessage = ({
187187
if (loadedState) {
188188
previousRunStateRef.current = loadedState.runState
189189
setRunState(loadedState.runState)
190-
const restoredMessages = sanitizeRestoredMessages(loadedState.messages)
191-
if (loadedState.runStateRestored) {
192-
setMessages(restoredMessages)
193-
} else {
194-
// The agent's context was lost (torn run-state.json, nothing
195-
// recoverable) while the transcript survived. Surface it: without
196-
// this the model just answers as if the earlier turns never
197-
// happened, which reads as the assistant being broken.
198-
setMessages([
199-
createErrorChatMessage(
200-
'The saved agent context could not be restored, so the assistant starts this chat without memory of earlier turns. The transcript below is intact.',
201-
),
202-
...restoredMessages,
203-
])
204-
}
190+
setMessages(sanitizeRestoredMessages(loadedState.messages))
205191
if (loadedState.chatId) {
206192
setCurrentChatId(loadedState.chatId)
207193
}

‎cli/src/utils/__tests__/run-state-storage.test.ts‎

Lines changed: 0 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -917,146 +917,3 @@ describe('poisoned payload persistence', () => {
917917
expect(block.outputRaw.self).toBe('[Circular]')
918918
})
919919
})
920-
921-
describe('run state recovery', () => {
922-
// Point persistence at a temp dir via the explicit test override.
923-
const chatDir = path.join(TEST_ROOT, 'codebuff-test-recovery')
924-
925-
const runStateWithSession = (marker: string): RunState =>
926-
({
927-
sessionState: {
928-
mainAgentState: {
929-
messageHistory: [{ role: 'user', content: marker }],
930-
},
931-
},
932-
output: { type: 'lastMessage', value: marker },
933-
traceSessionId: 'trace-1',
934-
}) as unknown as RunState
935-
936-
const runStatePath = path.join(chatDir, 'run-state.json')
937-
const bakPath = runStatePath + '.bak'
938-
const messagesPath = path.join(chatDir, 'chat-messages.json')
939-
940-
const writePrimary = (contents: string) =>
941-
fs.writeFileSync(runStatePath, contents)
942-
const validMessages = JSON.stringify([
943-
{
944-
id: 'msg-1',
945-
variant: 'user',
946-
content: 'the prompt',
947-
timestamp: new Date().toISOString(),
948-
},
949-
] as ChatMessage[])
950-
951-
beforeEach(() => {
952-
fs.rmSync(chatDir, { recursive: true, force: true })
953-
fs.mkdirSync(chatDir, { recursive: true })
954-
setChatDirOverrideForTesting(chatDir)
955-
})
956-
957-
afterEach(() => {
958-
setChatDirOverrideForTesting(undefined)
959-
})
960-
961-
test('recovers agent context from the .bak when the primary is torn', () => {
962-
writePrimary('{"sessionState": {"main"') // torn: power loss after rename
963-
fs.writeFileSync(bakPath, JSON.stringify(runStateWithSession('from-bak')))
964-
fs.writeFileSync(messagesPath, validMessages)
965-
966-
const loaded = loadMostRecentChatState()
967-
expect(loaded).not.toBeNull()
968-
// Agent context survived — the model is NOT amnesiac next turn.
969-
expect((loaded!.runState as any).sessionState).toBeDefined()
970-
expect(loaded!.runStateRestored).toBe(true)
971-
// Self-healed: the primary is the recovered generation again.
972-
expect(
973-
JSON.parse(fs.readFileSync(runStatePath, 'utf8')).output.value,
974-
).toBe('from-bak')
975-
})
976-
977-
test('recovers from the newest complete checkpoint temp when bak is absent', () => {
978-
writePrimary('{ torn')
979-
fs.writeFileSync(messagesPath, validMessages)
980-
// Two temps: an older torn one and a newer complete one (SIGKILL between
981-
// write and rename leaves the latter behind).
982-
fs.writeFileSync(
983-
runStatePath + '.999.oldest.tmp',
984-
'{"half":',
985-
)
986-
fs.writeFileSync(
987-
runStatePath + '.1234.newest.tmp',
988-
JSON.stringify(runStateWithSession('from-tmp')),
989-
)
990-
991-
const loaded = loadMostRecentChatState()
992-
expect(loaded).not.toBeNull()
993-
expect((loaded!.runState as any).sessionState).toBeDefined()
994-
expect(loaded!.runStateRestored).toBe(true)
995-
// Self-healed into the primary.
996-
expect(
997-
JSON.parse(fs.readFileSync(runStatePath, 'utf8')).output.value,
998-
).toBe('from-tmp')
999-
})
1000-
1001-
test('flags a healthy primary as fully restored', () => {
1002-
writePrimary(JSON.stringify(runStateWithSession('healthy')))
1003-
fs.writeFileSync(messagesPath, validMessages)
1004-
1005-
const loaded = loadMostRecentChatState()
1006-
expect(loaded!.runStateRestored).toBe(true)
1007-
expect((loaded!.runState as any).sessionState).toBeDefined()
1008-
})
1009-
1010-
test('falls back to a context-less placeholder with the loss flagged when nothing recovers', () => {
1011-
writePrimary('{ torn')
1012-
fs.writeFileSync(messagesPath, validMessages)
1013-
1014-
const loaded = loadMostRecentChatState()
1015-
expect(loaded).not.toBeNull()
1016-
// The amnesia carrier: no sessionState — the SDK will start a fresh
1017-
// session next turn. runStateRestored=false is what makes the UI say so
1018-
// instead of the model silently forgetting every earlier turn.
1019-
expect((loaded!.runState as any).sessionState).toBeUndefined()
1020-
expect(loaded!.runStateRestored).toBe(false)
1021-
// The transcript still survives.
1022-
expect(loaded!.messages.length).toBe(1)
1023-
})
1024-
1025-
test('saveChatState rotates the previous primary into .bak', () => {
1026-
saveChatState(runStateWithSession('generation-1'), [
1027-
{
1028-
id: 'msg-1',
1029-
variant: 'user',
1030-
content: 'first',
1031-
timestamp: new Date().toISOString(),
1032-
},
1033-
])
1034-
expect(fs.existsSync(bakPath)).toBe(false)
1035-
1036-
saveChatState(runStateWithSession('generation-2'), [
1037-
{
1038-
id: 'msg-2',
1039-
variant: 'user',
1040-
content: 'second',
1041-
timestamp: new Date().toISOString(),
1042-
},
1043-
])
1044-
1045-
expect(JSON.parse(fs.readFileSync(runStatePath, 'utf8')).output.value).toBe(
1046-
'generation-2',
1047-
)
1048-
expect(JSON.parse(fs.readFileSync(bakPath, 'utf8')).output.value).toBe(
1049-
'generation-1',
1050-
)
1051-
})
1052-
1053-
test('clearChatState removes the backup too', () => {
1054-
writePrimary(JSON.stringify(runStateWithSession('x')))
1055-
fs.writeFileSync(bakPath, JSON.stringify(runStateWithSession('bak')))
1056-
fs.writeFileSync(messagesPath, validMessages)
1057-
1058-
clearChatState()
1059-
expect(fs.existsSync(runStatePath)).toBe(false)
1060-
expect(fs.existsSync(bakPath)).toBe(false)
1061-
})
1062-
})

‎cli/src/utils/__tests__/write-file-atomic.test.ts‎

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
1+
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
22
import * as fs from 'fs'
33
import * as os from 'os'
44
import * as path from 'path'
@@ -124,4 +124,75 @@ describe('writeFileAtomicAsync', () => {
124124
)
125125
expect(fs.readdirSync(tempDir)).toEqual(['out.json'])
126126
})
127+
128+
test('retries a transient Windows rename lock and succeeds', async () => {
129+
const target = path.join(tempDir, 'out.json')
130+
let attempts = 0
131+
const realRename = fs.promises.rename.bind(fs.promises)
132+
const spy = spyOn(fs.promises, 'rename').mockImplementation(
133+
async (from, to) => {
134+
attempts++
135+
if (attempts <= 2) {
136+
throw Object.assign(new Error('locked'), { code: 'EPERM' })
137+
}
138+
return realRename(from, to)
139+
},
140+
)
141+
try {
142+
await writeFileAtomicAsync(target, 'recovered')
143+
} finally {
144+
spy.mockRestore()
145+
}
146+
147+
expect(attempts).toBe(3)
148+
expect(fs.readFileSync(target, 'utf8')).toBe('recovered')
149+
})
150+
151+
test('rethrows a non-transient rename error immediately', async () => {
152+
const target = path.join(tempDir, 'out.json')
153+
let attempts = 0
154+
const spy = spyOn(fs.promises, 'rename').mockImplementation(async () => {
155+
attempts++
156+
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
157+
})
158+
try {
159+
await expect(writeFileAtomicAsync(target, 'data')).rejects.toThrow()
160+
} finally {
161+
spy.mockRestore()
162+
}
163+
164+
expect(attempts).toBe(1)
165+
})
166+
})
167+
168+
describe('writeFileAtomic durability ordering', () => {
169+
beforeEach(() => {
170+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codebuff-atomic-'))
171+
})
172+
173+
afterEach(() => {
174+
fs.rmSync(tempDir, { recursive: true, force: true })
175+
})
176+
177+
test('fsyncs the temp before the rename goes live', () => {
178+
const target = path.join(tempDir, 'out.json')
179+
const order: string[] = []
180+
const fsyncSpy = spyOn(fs, 'fsyncSync').mockImplementation(() => {
181+
order.push('fsync')
182+
})
183+
const realRename = fs.renameSync.bind(fs)
184+
const renameSpy = spyOn(fs, 'renameSync').mockImplementation((from, to) => {
185+
order.push('rename')
186+
return realRename(from, to)
187+
})
188+
try {
189+
writeFileAtomic(target, '{"a":1}')
190+
} finally {
191+
fsyncSpy.mockRestore()
192+
renameSpy.mockRestore()
193+
}
194+
195+
expect(order).toEqual(['fsync', 'rename'])
196+
expect(fs.readFileSync(target, 'utf8')).toBe('{"a":1}')
197+
})
127198
})

0 commit comments

Comments
 (0)