fix(session): make transcript replacements crash-safe (#2094)

* fix(session): make transcript replacements crash-safe

Complete transcript rewrites could truncate live JSONL files before preserved data was durable, risking unrecoverable resume history after an interrupted write. Commit replacements through exclusive sibling temp files and serialize them with all transcript append paths so readers observe either the old file or the complete replacement.

* fix(session): preserve concurrent transcript updates

Abort tombstone commits when the scanned transcript changes before replacement, and keep existing local history when remote foreground hydration returns no entries. Harden the associated portability, option coverage, queue timing, and diagnostics.

* test(session): match hydration reader signature

Pass the explicit optional subagent reader in the empty-hydration regression so a fresh TypeScript build sees the complete helper signature.

* fix(session): coordinate transcript writers across processes

Hold a same-directory cooperative lock across transcript replacement and final-line truncation, and make session plus SDK append paths participate. Exercise the post-validation/pre-rename race deterministically so external appends land after the complete commit.

* test(session): provide empty hydration subagent reader

* fix(session): scope transcript lock ownership

Separate async and synchronous lock ownership so unrelated sync appends cannot bypass an in-flight replacement. Route aliased in-process appends through the queue, propagate lock compromise through AbortSignal, and cover both symlink-alias and rename-boundary races.
This commit is contained in:
Bogdan
2026-08-07 09:57:32 +08:00
committed by GitHub
parent 6465a516f2
commit d834904e5a
7 changed files with 2318 additions and 172 deletions
+5 -5
View File
@@ -18,6 +18,7 @@ import {
resolveSessionFilePath,
} from '../../utils/sessionStoragePortable.js'
import { readJSONLFile } from '../../utils/json.js'
import { withTranscriptFileLock } from '../../utils/transcriptFileLock.js'
import {
assertValidSessionId,
type JsonlEntry,
@@ -222,12 +223,11 @@ async function appendJsonlEntry(
entry: Record<string, unknown>,
): Promise<void> {
const line = JSON.stringify(entry) + '\n'
try {
await mkdir(dirname(filePath), { mode: 0o700, recursive: true })
await withTranscriptFileLock(filePath, async signal => {
signal.throwIfAborted()
await appendFile(filePath, line, { mode: 0o600 })
} catch {
await mkdir(dirname(filePath), { mode: 0o700, recursive: true })
await appendFile(filePath, line, { mode: 0o600 })
}
})
}
// ============================================================================
+4 -7
View File
@@ -1,6 +1,6 @@
import { randomUUID } from 'crypto'
import { rm } from 'fs'
import { appendFile, copyFile, mkdir } from 'fs/promises'
import { copyFile, mkdir } from 'fs/promises'
import { dirname, isAbsolute, join, relative } from 'path'
import { getCwdState } from '../../bootstrap/state.js'
import type { CompletionBoundary } from '../../state/AppStateStore.js'
@@ -45,8 +45,7 @@ import {
} from '../../utils/messages.js'
import { getClaudeTempDir } from '../../utils/permissions/filesystem.js'
import { extractReadFilesFromMessages } from '../../utils/queryHelpers.js'
import { getTranscriptPath } from '../../utils/sessionStorage.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { recordSpeculationAccept } from '../../utils/sessionStorage.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
@@ -797,11 +796,9 @@ export async function acceptSpeculation(
timestamp: new Date().toISOString(),
timeSavedMs,
}
void appendFile(getTranscriptPath(), jsonStringify(entry) + '\n', {
mode: 0o600,
}).catch(() => {
void recordSpeculationAccept(entry).catch(() => {
logForDebugging(
'[Speculation] Failed to write speculation-accept to transcript',
'[Speculation] Failed to queue speculation-accept for transcript',
)
})
}
+273
View File
@@ -0,0 +1,273 @@
import { afterEach, beforeEach, expect, test } from 'bun:test'
import {
chmod,
lstat,
mkdtemp,
readFile,
readlink,
readdir,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import {
type AtomicReplaceFaultStage,
replaceFileAtomic,
resetAtomicReplaceFaultInjectorForTesting,
setAtomicReplaceFaultInjectorForTesting,
setAtomicReplaceWriteLimitForTesting,
} from './atomicReplace.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
const tempDirs: string[] = []
async function tempTarget(initial?: string): Promise<{
dir: string
target: string
}> {
const dir = await mkdtemp(join(tmpdir(), 'openclaude-atomic-replace-'))
tempDirs.push(dir)
const target = join(dir, 'transcript.jsonl')
if (initial !== undefined) await writeFile(target, initial)
return { dir, target }
}
async function tempFiles(dir: string, target: string): Promise<string[]> {
const prefix = `.${basename(target)}.tmp-`
return (await readdir(dir)).filter(name => name.startsWith(prefix))
}
beforeEach(async () => {
await acquireSharedMutationLock('utils/atomicReplace.test.ts')
})
afterEach(async () => {
try {
resetAtomicReplaceFaultInjectorForTesting()
setAtomicReplaceWriteLimitForTesting(undefined)
await Promise.all(
tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })),
)
} finally {
releaseSharedMutationLock()
}
})
test('replaces from strings, bytes, and streamed chunks', async () => {
const { target } = await tempTarget('old')
await replaceFileAtomic(target, 'string')
expect(await readFile(target, 'utf8')).toBe('string')
await replaceFileAtomic(target, new TextEncoder().encode('bytes'))
expect(await readFile(target, 'utf8')).toBe('bytes')
async function* chunks() {
yield 'stream-'
yield new TextEncoder().encode('complete')
}
await replaceFileAtomic(target, chunks())
expect(await readFile(target, 'utf8')).toBe('stream-complete')
})
test('retries deterministic short low-level writes until the chunk is complete', async () => {
const { target } = await tempTarget('old-complete')
setAtomicReplaceWriteLimitForTesting(3)
await replaceFileAtomic(target, 'new-complete-transcript')
expect(await readFile(target, 'utf8')).toBe('new-complete-transcript')
})
test('a zero-progress low-level write preserves the original and cleans the temp', async () => {
const { dir, target } = await tempTarget('old-complete')
setAtomicReplaceWriteLimitForTesting(0)
await expect(replaceFileAtomic(target, 'new')).rejects.toThrow(
'Atomic replacement made no progress while writing',
)
expect(await readFile(target, 'utf8')).toBe('old-complete')
expect(await tempFiles(dir, target)).toEqual([])
})
test('preserves an existing restrictive mode and creates new files as 0600', async () => {
if (process.platform === 'win32') return
const existing = await tempTarget('old')
await chmod(existing.target, 0o640)
await replaceFileAtomic(existing.target, 'new')
expect((await stat(existing.target)).mode & 0o777).toBe(0o640)
const created = await tempTarget()
await replaceFileAtomic(created.target, 'new')
expect((await stat(created.target)).mode & 0o777).toBe(0o600)
})
test('an explicit mode overrides preservation for an existing target', async () => {
if (process.platform === 'win32') return
const existing = await tempTarget('old')
await chmod(existing.target, 0o640)
await replaceFileAtomic(existing.target, 'new', { mode: 0o600 })
expect((await stat(existing.target)).mode & 0o777).toBe(0o600)
})
test('preserveMode false applies the private default to an existing target', async () => {
if (process.platform === 'win32') return
const existing = await tempTarget('old')
await chmod(existing.target, 0o644)
await replaceFileAtomic(existing.target, 'new', { preserveMode: false })
expect((await stat(existing.target)).mode & 0o777).toBe(0o600)
})
test('full flush commits complete content', async () => {
const { target } = await tempTarget('old')
await replaceFileAtomic(target, 'new-complete', { flush: 'full' })
expect(await readFile(target, 'utf8')).toBe('new-complete')
})
test('writes through live and dangling relative symlinks without replacing them', async () => {
if (process.platform === 'win32') return
const live = await tempTarget()
const liveTarget = join(live.dir, 'live-target.jsonl')
await writeFile(liveTarget, 'old-live')
await symlink(basename(liveTarget), live.target)
await replaceFileAtomic(live.target, 'new-live')
expect((await lstat(live.target)).isSymbolicLink()).toBe(true)
expect(await readlink(live.target)).toBe(basename(liveTarget))
expect(await readFile(liveTarget, 'utf8')).toBe('new-live')
const dangling = await tempTarget()
const danglingTarget = join(dangling.dir, 'created-through-link.jsonl')
await symlink(basename(danglingTarget), dangling.target)
await replaceFileAtomic(dangling.target, 'new-dangling')
expect((await lstat(dangling.target)).isSymbolicLink()).toBe(true)
expect(await readFile(danglingTarget, 'utf8')).toBe('new-dangling')
})
test('an already-aborted replacement preserves the original', async () => {
const { target } = await tempTarget('old')
const controller = new AbortController()
controller.abort()
await expect(
replaceFileAtomic(target, 'new', { signal: controller.signal }),
).rejects.toBeDefined()
expect(await readFile(target, 'utf8')).toBe('old')
})
test('an abort at the rename boundary preserves the original', async () => {
const { dir, target } = await tempTarget('old')
const controller = new AbortController()
setAtomicReplaceFaultInjectorForTesting(stage => {
if (stage === 'rename') controller.abort(new Error('lock compromised'))
})
await expect(
replaceFileAtomic(target, 'new', { signal: controller.signal }),
).rejects.toThrow('lock compromised')
expect(await readFile(target, 'utf8')).toBe('old')
expect(await tempFiles(dir, target)).toEqual([])
})
const preRenameFaults: AtomicReplaceFaultStage[] = [
'temp-open',
'stream-write',
'data-flush',
'chmod',
'close',
'rename',
]
for (const faultStage of preRenameFaults) {
test(`${faultStage} failure preserves the original and cleans the temp`, async () => {
const { dir, target } = await tempTarget('old-complete')
setAtomicReplaceFaultInjectorForTesting(stage => {
if (stage === faultStage) throw new Error(`fault:${stage}`)
})
async function* replacement() {
yield 'partial-'
yield 'replacement'
}
await expect(replaceFileAtomic(target, replacement())).rejects.toThrow(
`fault:${faultStage}`,
)
expect(await readFile(target, 'utf8')).toBe('old-complete')
expect(await tempFiles(dir, target)).toEqual([])
})
}
test('cleanup failure does not mask the primary failure or modify the target', async () => {
const { dir, target } = await tempTarget('old')
setAtomicReplaceFaultInjectorForTesting(stage => {
if (stage === 'rename') throw new Error('primary rename fault')
if (stage === 'cleanup') throw new Error('cleanup fault')
})
await expect(replaceFileAtomic(target, 'new')).rejects.toThrow(
'primary rename fault',
)
expect(await readFile(target, 'utf8')).toBe('old')
expect((await tempFiles(dir, target)).length).toBe(1)
})
test('directory sync failure is post-commit and leaves the complete new file', async () => {
const { target } = await tempTarget('old')
setAtomicReplaceFaultInjectorForTesting(stage => {
if (stage === 'directory-sync') throw new Error('directory sync fault')
})
await replaceFileAtomic(target, 'new-complete')
expect(await readFile(target, 'utf8')).toBe('new-complete')
})
test('concurrent readers observe only complete old or complete new bytes', async () => {
const oldContent = 'old-complete-transcript'
const newContent = 'new-complete-transcript'
const { target } = await tempTarget(oldContent)
let release!: () => void
const gate = new Promise<void>(resolve => {
release = resolve
})
let firstWrite!: () => void
const wroteFirstChunk = new Promise<void>(resolve => {
firstWrite = resolve
})
let writes = 0
setAtomicReplaceFaultInjectorForTesting(stage => {
if (stage === 'stream-write' && writes++ === 0) firstWrite()
})
async function* slowReplacement() {
yield 'new-complete-'
await gate
yield 'transcript'
}
const replacing = replaceFileAtomic(target, slowReplacement())
await wroteFirstChunk
for (let i = 0; i < 25; i++) {
expect(await readFile(target, 'utf8')).toBe(oldContent)
}
release()
await replacing
expect(await readFile(target, 'utf8')).toBe(newContent)
})
+294
View File
@@ -0,0 +1,294 @@
import { randomBytes } from 'node:crypto'
import type { FileHandle } from 'node:fs/promises'
import {
lstat,
open,
readlink,
realpath,
rename,
stat,
unlink,
} from 'node:fs/promises'
import {
basename,
dirname,
isAbsolute,
join,
resolve,
} from 'node:path'
import { getErrnoCode } from './errors.js'
export type AtomicReplaceOptions = {
mode?: number
preserveMode?: boolean
signal?: AbortSignal
flush?: 'data' | 'full'
/** Refuse to commit if a caller's existing-file snapshot is stale. */
expectedTargetSize?: number
}
export type AtomicReplaceFaultStage =
| 'temp-open'
| 'stream-write'
| 'data-flush'
| 'chmod'
| 'close'
| 'rename'
| 'directory-sync'
| 'cleanup'
export type AtomicReplaceFaultContext = {
requestedPath: string
targetPath: string
tempPath?: string
}
type AtomicReplaceFaultInjector = (
stage: AtomicReplaceFaultStage,
context: AtomicReplaceFaultContext,
) => void | Promise<void>
let faultInjector: AtomicReplaceFaultInjector | undefined
let writeLimitForTesting: number | undefined
/** @internal Test-only deterministic failure injection. */
export function setAtomicReplaceFaultInjectorForTesting(
injector: AtomicReplaceFaultInjector,
): void {
faultInjector = injector
}
/** @internal Reset test-only deterministic failure injection. */
export function resetAtomicReplaceFaultInjectorForTesting(): void {
faultInjector = undefined
}
/** @internal Limit each low-level write for deterministic short-write tests. */
export function setAtomicReplaceWriteLimitForTesting(
limit: number | undefined,
): void {
writeLimitForTesting = limit
}
async function injectFault(
stage: AtomicReplaceFaultStage,
context: AtomicReplaceFaultContext,
): Promise<void> {
await faultInjector?.(stage, context)
}
async function resolveWriteTarget(requestedPath: string): Promise<string> {
let currentPath = resolve(requestedPath)
const visited = new Set<string>()
for (let depth = 0; depth < 40; depth++) {
if (visited.has(currentPath)) {
throw new Error(
`Cannot atomically replace circular symlink: ${requestedPath}`,
)
}
visited.add(currentPath)
let fileStat
try {
fileStat = await lstat(currentPath)
} catch (error) {
if (getErrnoCode(error) === 'ENOENT') return currentPath
throw error
}
if (!fileStat.isSymbolicLink()) return currentPath
try {
return await realpath(currentPath)
} catch {
const linkTarget = await readlink(currentPath)
currentPath = isAbsolute(linkTarget)
? linkTarget
: resolve(dirname(currentPath), linkTarget)
}
}
throw new Error(`Cannot atomically replace symlink chain: ${requestedPath}`)
}
function isAsyncIterable(
data: unknown,
): data is AsyncIterable<string | Uint8Array> {
return (
typeof data === 'object' &&
data !== null &&
Symbol.asyncIterator in data &&
typeof data[Symbol.asyncIterator] === 'function'
)
}
async function writeChunkFully(
handle: FileHandle,
chunk: string | Uint8Array,
signal: AbortSignal | undefined,
context: AtomicReplaceFaultContext,
): Promise<void> {
const buffer =
typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : Buffer.from(chunk)
let offset = 0
while (offset < buffer.length) {
signal?.throwIfAborted()
const remaining = buffer.length - offset
const writeLength =
writeLimitForTesting === undefined
? remaining
: Math.min(remaining, writeLimitForTesting)
const { bytesWritten } = await handle.write(
buffer,
offset,
writeLength,
null,
)
if (bytesWritten === 0) {
throw new Error('Atomic replacement made no progress while writing')
}
offset += bytesWritten
await injectFault('stream-write', context)
}
}
async function writeReplacement(
handle: FileHandle,
data: string | Uint8Array | AsyncIterable<string | Uint8Array>,
signal: AbortSignal | undefined,
context: AtomicReplaceFaultContext,
): Promise<void> {
if (isAsyncIterable(data)) {
for await (const chunk of data) {
await writeChunkFully(handle, chunk, signal, context)
}
return
}
await writeChunkFully(handle, data, signal, context)
}
async function syncDirectoryBestEffort(
targetPath: string,
context: AtomicReplaceFaultContext,
): Promise<void> {
if (process.platform === 'win32') return
let directoryHandle: FileHandle | undefined
try {
directoryHandle = await open(dirname(targetPath), 'r')
await injectFault('directory-sync', context)
await directoryHandle.sync()
} catch {
// The rename is already committed. Some filesystems do not support
// syncing directory handles, so directory durability is best-effort.
} finally {
try {
await directoryHandle?.close()
} catch {
// Best-effort directory sync must not turn a committed write into failure.
}
}
}
/**
* Replace a regular file through an exclusive sibling temp and atomic rename.
* No failure before rename modifies or unlinks the target.
*/
export async function replaceFileAtomic(
requestedPath: string,
data: string | Uint8Array | AsyncIterable<string | Uint8Array>,
options: AtomicReplaceOptions = {},
): Promise<void> {
options.signal?.throwIfAborted()
const targetPath = await resolveWriteTarget(requestedPath)
let replacementMode = options.mode ?? 0o600
try {
const targetStat = await stat(targetPath)
if (!targetStat.isFile()) {
throw new Error(
`Atomic replacement target is not a regular file: ${requestedPath}`,
)
}
if (options.mode === undefined && options.preserveMode !== false) {
replacementMode = targetStat.mode & 0o7777
}
} catch (error) {
if (getErrnoCode(error) !== 'ENOENT') throw error
}
const tempPath = join(
dirname(targetPath),
`.${basename(targetPath)}.tmp-${randomBytes(16).toString('hex')}`,
)
const context: AtomicReplaceFaultContext = {
requestedPath,
targetPath,
tempPath,
}
let handle: FileHandle | undefined
let tempCreated = false
let committed = false
try {
options.signal?.throwIfAborted()
await injectFault('temp-open', context)
handle = await open(tempPath, 'wx', 0o600)
tempCreated = true
await writeReplacement(handle, data, options.signal, context)
options.signal?.throwIfAborted()
await injectFault('data-flush', context)
if (options.flush === 'full') {
await handle.sync()
} else {
await handle.datasync()
}
await injectFault('chmod', context)
await handle.chmod(replacementMode)
await handle.close()
handle = undefined
await injectFault('close', context)
options.signal?.throwIfAborted()
if (
options.expectedTargetSize !== undefined &&
(await stat(targetPath)).size !== options.expectedTargetSize
) {
throw new Error('Atomic replacement target changed before commit')
}
await injectFault('rename', context)
options.signal?.throwIfAborted()
await rename(tempPath, targetPath)
committed = true
await syncDirectoryBestEffort(targetPath, context)
} catch (error) {
if (handle) {
try {
await handle.close()
} catch {
// Preserve the operation error; cleanup below still gets a chance.
}
}
if (tempCreated && !committed) {
try {
await injectFault('cleanup', context)
await unlink(tempPath)
} catch {
// Preserve the primary operation error.
}
}
throw error
}
}
@@ -0,0 +1,807 @@
import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test'
import type { UUID } from 'node:crypto'
import {
chmod,
lstat,
mkdir,
mkdtemp,
open,
readFile,
rm,
stat,
symlink,
writeFile,
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import {
getOriginalCwd,
getSessionId,
isSessionPersistenceDisabled,
setOriginalCwd,
setSessionPersistenceDisabled,
switchSession,
} from '../bootstrap/state.js'
import { renameSession } from '../entrypoints/sdk/sessions.js'
import * as sessionIngress from '../services/api/sessionIngress.js'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import type { Message } from '../types/message.js'
import {
resetAtomicReplaceFaultInjectorForTesting,
setAtomicReplaceFaultInjectorForTesting,
} from './atomicReplace.js'
import {
getClaudeConfigHomeDirOverrideForTesting,
setClaudeConfigHomeDirForTesting,
} from './envUtils.js'
import { isTranscriptFileLockHeldForTesting } from './transcriptFileLock.js'
import {
buildConversationChain,
flushSessionStorage,
getAgentTranscriptPath,
getProjectDir,
getTranscriptPathForSession,
hydrateFromCCRv2InternalEvents,
hydrateRemoteSession,
loadTranscriptFile,
recordGoalState,
recordSpeculationAccept,
recordTranscript,
removeTranscriptMessage,
resetTranscriptRewriteHooksForTesting,
resetProjectForTesting,
saveCustomTitle,
setInternalEventReader,
setSessionFileForTesting,
setTranscriptRewriteHooksForTesting,
} from './sessionStorage.js'
const SESSION_ID = '10000000-0000-4000-8000-000000000001'
const OTHER_SESSION_ID = '10000000-0000-4000-8000-000000000002'
const TARGET = '20000000-0000-4000-8000-000000000001' as UUID
const KEEP_1 = '20000000-0000-4000-8000-000000000002' as UUID
const KEEP_2 = '20000000-0000-4000-8000-000000000003' as UUID
const TIMESTAMP = '2026-08-05T00:00:00.000Z'
let testRoot = ''
let originalCwd = ''
let originalSessionId = ''
let originalConfigOverride: string | undefined
let originalPersistenceDisabled = false
let originalNodeEnv: string | undefined
let originalTestPersistence: string | undefined
let originalPersistence: string | undefined
let originalDiagnosticsFile: string | undefined
function line(uuid: UUID, extra: Record<string, unknown> = {}): string {
return JSON.stringify({ type: 'test', uuid, ...extra })
}
function message(uuid: UUID, content: string): Message {
return {
type: 'user',
uuid,
timestamp: TIMESTAMP,
message: { role: 'user', content },
isMeta: false,
}
}
async function useTranscript(
content: string | Uint8Array,
name = 'session.jsonl',
): Promise<string> {
const filePath = join(testRoot, name)
await writeFile(filePath, content)
switchSession(SESSION_ID as never, testRoot)
resetProjectForTesting()
setSessionFileForTesting(filePath)
return filePath
}
async function prepareHydration(): Promise<string> {
const configDir = join(testRoot, 'config')
const workspaceDir = join(testRoot, 'workspace')
await mkdir(workspaceDir, { recursive: true })
setClaudeConfigHomeDirForTesting(configDir)
setOriginalCwd(workspaceDir)
switchSession(SESSION_ID as never)
resetProjectForTesting()
const transcriptPath = getTranscriptPathForSession(SESSION_ID)
await mkdir(dirname(transcriptPath), { recursive: true, mode: 0o700 })
return transcriptPath
}
beforeEach(async () => {
await acquireSharedMutationLock('utils/sessionStorage.atomicReplace.test.ts')
testRoot = await mkdtemp(join(tmpdir(), 'openclaude-atomic-session-'))
originalCwd = getOriginalCwd()
originalSessionId = getSessionId()
originalConfigOverride = getClaudeConfigHomeDirOverrideForTesting()
originalPersistenceDisabled = isSessionPersistenceDisabled()
originalNodeEnv = process.env.NODE_ENV
originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE
originalPersistence = process.env.ENABLE_SESSION_PERSISTENCE
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
process.env.NODE_ENV = 'development'
process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true'
process.env.ENABLE_SESSION_PERSISTENCE = 'true'
setSessionPersistenceDisabled(false)
})
afterEach(async () => {
try {
resetAtomicReplaceFaultInjectorForTesting()
resetTranscriptRewriteHooksForTesting()
mock.restore()
resetProjectForTesting()
switchSession(originalSessionId as never)
setOriginalCwd(originalCwd)
setClaudeConfigHomeDirForTesting(originalConfigOverride)
setSessionPersistenceDisabled(originalPersistenceDisabled)
if (originalNodeEnv === undefined) delete process.env.NODE_ENV
else process.env.NODE_ENV = originalNodeEnv
if (originalTestPersistence === undefined) {
delete process.env.TEST_ENABLE_SESSION_PERSISTENCE
} else {
process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersistence
}
if (originalPersistence === undefined) {
delete process.env.ENABLE_SESSION_PERSISTENCE
} else {
process.env.ENABLE_SESSION_PERSISTENCE = originalPersistence
}
if (originalDiagnosticsFile === undefined) {
delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
} else {
process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = originalDiagnosticsFile
}
await rm(testRoot, { recursive: true, force: true })
} finally {
releaseSharedMutationLock()
}
})
test('final-line tombstones preserve LF, CRLF, and no-final-newline conventions', async () => {
for (const [separator, finalNewline] of [
['\n', true],
['\n', false],
['\r\n', true],
['\r\n', false],
] as const) {
const prefix = line(KEEP_1)
const original = `${prefix}${separator}${line(TARGET)}${finalNewline ? separator : ''}`
const filePath = await useTranscript(original, `final-${separator.length}-${finalNewline}.jsonl`)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(
finalNewline ? `${prefix}${separator}` : prefix,
)
}
})
test('middle-line tombstone preserves surrounding bytes and malformed lines', async () => {
const malformed = '{not valid json but must survive}\r\n'
const prefix = `${line(KEEP_1, { spacing: 'kept' })}\r\n${malformed}`
const suffix = `${line(KEEP_2, { nested: { uuid: TARGET } })}\r\n`
const filePath = await useTranscript(`${prefix}${line(TARGET)}\r\n${suffix}`)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(prefix + suffix)
})
test('tail search ignores a nested uuid in a later entry', async () => {
const original = `${line(TARGET)}\n${line(KEEP_1, { nested: { uuid: TARGET } })}\n`
const filePath = await useTranscript(original)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(
`${line(KEEP_1, { nested: { uuid: TARGET } })}\n`,
)
})
test('slow tombstone removes a target outside the tail window', async () => {
const suffix = `${line(KEEP_1, { payload: 'x'.repeat(70 * 1024) })}\n${line(KEEP_2)}\n`
const filePath = await useTranscript(`${line(TARGET)}\n${suffix}`)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(suffix)
})
test('slow tombstone handles a target line longer than the tail window', async () => {
const targetLine = line(TARGET, { payload: 'x'.repeat(70 * 1024) })
const suffix = `${line(KEEP_1)}\n`
const filePath = await useTranscript(`${targetLine}\n${suffix}`)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(suffix)
})
test('large slow rewrite streams bounded chunks instead of building a second copy', async () => {
const suffix = `${line(KEEP_1, { payload: 'x'.repeat(8 * 1024 * 1024) })}\n`
const filePath = await useTranscript(`${line(TARGET)}\n${suffix}`)
let writeCount = 0
setAtomicReplaceFaultInjectorForTesting((stage, context) => {
if (stage === 'stream-write' && context.requestedPath === filePath) {
writeCount++
}
})
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect((await stat(filePath)).size).toBe(Buffer.byteLength(suffix))
// Prove bounded streaming without coupling the test to the current chunk size.
expect(writeCount).toBeGreaterThan(10)
expect(await readFile(filePath, 'utf8')).toBe(suffix)
})
test('empty file and missing target remain byte-for-byte unchanged', async () => {
const emptyPath = await useTranscript('', 'empty.jsonl')
await removeTranscriptMessage(TARGET)
expect(await readFile(emptyPath)).toEqual(Buffer.alloc(0))
const original = `${line(KEEP_1)}\n${line(KEEP_2)}\n`
const missingPath = await useTranscript(original, 'missing.jsonl')
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(missingPath, 'utf8')).toBe(original)
})
test('maximum rewrite guard leaves an oversized transcript unchanged', async () => {
const filePath = await useTranscript(`${line(TARGET)}\n`, 'guard.jsonl')
const handle = await open(filePath, 'r+')
await handle.truncate(50 * 1024 * 1024 + 1)
await handle.close()
const before = await stat(filePath)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect((await stat(filePath)).size).toBe(before.size)
const head = Buffer.alloc(Buffer.byteLength(line(TARGET)))
const readHandle = await open(filePath, 'r')
await readHandle.read(head, 0, head.length, 0)
await readHandle.close()
expect(head.toString()).toBe(line(TARGET))
})
test.each(['stream-write', 'data-flush', 'rename'] as const)(
'%s failure preserves the old transcript and cleans its temp file',
async stage => {
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
const filePath = await useTranscript(original)
setAtomicReplaceFaultInjectorForTesting((actualStage, context) => {
if (actualStage === stage && context.requestedPath === filePath) {
throw new Error(`injected ${stage}`)
}
})
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(original)
expect(
(await Array.fromAsync(new Bun.Glob('.*.tmp-*').scan(testRoot))).length,
).toBe(0)
},
)
test('an external append after validation waits for the tombstone commit', async () => {
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
const appended = `${JSON.stringify({
type: 'custom-title',
customTitle: 'external SDK append',
sessionId: SESSION_ID,
})}\n`
const filePath = await prepareHydration()
await writeFile(filePath, original)
setSessionFileForTesting(filePath)
let injected = false
let appendPromise: Promise<void> | undefined
setAtomicReplaceFaultInjectorForTesting(async (stage, context) => {
if (!injected && stage === 'rename' && context.requestedPath === filePath) {
injected = true
expect(await isTranscriptFileLockHeldForTesting(filePath)).toBe(true)
appendPromise = renameSession(SESSION_ID, 'external SDK append', {
dir: getOriginalCwd(),
})
}
})
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(appendPromise).toBeDefined()
await appendPromise
expect(await readFile(filePath, 'utf8')).toBe(
`${line(KEEP_1)}\n${line(KEEP_2)}\n${appended}`,
)
expect(
(await Array.fromAsync(new Bun.Glob('.*.tmp-*').scan(testRoot))).length,
).toBe(0)
await expect(lstat(`${filePath}.lock`)).rejects.toMatchObject({ code: 'ENOENT' })
})
test('a synchronous append through a resolved symlink target waits for the rewrite lock', async () => {
if (process.platform === 'win32') return
const realPath = join(testRoot, 'sync-real.jsonl')
const linkPath = join(testRoot, 'sync-linked.jsonl')
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
await writeFile(realPath, original)
await symlink('sync-real.jsonl', linkPath)
switchSession(SESSION_ID as never, testRoot)
resetProjectForTesting()
setSessionFileForTesting(linkPath)
let injected = false
setAtomicReplaceFaultInjectorForTesting(async (stage, context) => {
if (!injected && stage === 'rename' && context.requestedPath === linkPath) {
injected = true
expect(await isTranscriptFileLockHeldForTesting(linkPath)).toBe(true)
await saveCustomTitle(
SESSION_ID as UUID,
'synchronous symlink append',
realPath,
)
}
})
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true)
const result = await readFile(realPath, 'utf8')
expect(result).toBe(
`${line(KEEP_1)}\n${line(KEEP_2)}\n${JSON.stringify({
type: 'custom-title',
customTitle: 'synchronous symlink append',
sessionId: SESSION_ID,
})}\n`,
)
})
test.each(['stream-write', 'data-flush', 'rename'] as const)(
'slow-path %s failure preserves the old transcript and cleans its temp file',
async stage => {
const suffix = `${line(KEEP_1, { payload: 'x'.repeat(70 * 1024) })}\n`
const original = `${line(TARGET)}\n${suffix}`
const filePath = await useTranscript(original)
setAtomicReplaceFaultInjectorForTesting((actualStage, context) => {
if (actualStage === stage && context.requestedPath === filePath) {
throw new Error(`injected slow ${stage}`)
}
})
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(original)
expect(
(await Array.fromAsync(new Bun.Glob('.*.tmp-*').scan(testRoot))).length,
).toBe(0)
},
)
test('middle tombstone preserves restrictive mode and follows the live symlink target', async () => {
if (process.platform === 'win32') return
const realPath = join(testRoot, 'real.jsonl')
const linkPath = join(testRoot, 'linked.jsonl')
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
await writeFile(realPath, original)
await chmod(realPath, 0o640)
await symlink('real.jsonl', linkPath)
switchSession(SESSION_ID as never, testRoot)
resetProjectForTesting()
setSessionFileForTesting(linkPath)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect((await lstat(linkPath)).isSymbolicLink()).toBe(true)
expect(await readFile(realPath, 'utf8')).toBe(`${line(KEEP_1)}\n${line(KEEP_2)}\n`)
expect((await stat(realPath)).mode & 0o777).toBe(0o640)
})
test('serialized appends before and during a paused rewrite survive in order', async () => {
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
const filePath = await useTranscript(original)
let release!: () => void
let paused!: () => void
const pausedPromise = new Promise<void>(resolve => {
paused = resolve
})
const releasePromise = new Promise<void>(resolve => {
release = resolve
})
let blocked = false
setAtomicReplaceFaultInjectorForTesting(async (stage, context) => {
if (!blocked && stage === 'stream-write' && context.requestedPath === filePath) {
blocked = true
paused()
await releasePromise
}
})
await recordTranscript([
message('20000000-0000-4000-8000-000000000004' as UUID, 'before rewrite'),
])
const priorGoalWrite = recordGoalState(
{
id: 'goal-before-rewrite',
condition: 'serialize before replacement',
status: 'active',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
startedAt: TIMESTAMP,
turnCount: 0,
maxTurns: 10,
evaluatorFailures: 0,
},
SESSION_ID as UUID,
)
const removal = removeTranscriptMessage(TARGET)
await pausedPromise
await saveCustomTitle(SESSION_ID as UUID, 'during rewrite', filePath)
const goalWrite = recordGoalState(
{
id: 'goal-during-rewrite',
condition: 'preserve queued data',
status: 'active',
createdAt: TIMESTAMP,
updatedAt: TIMESTAMP,
startedAt: TIMESTAMP,
turnCount: 0,
maxTurns: 10,
evaluatorFailures: 0,
},
SESSION_ID as UUID,
)
release()
await Promise.all([priorGoalWrite, removal, goalWrite])
await flushSessionStorage()
const result = await readFile(filePath, 'utf8')
expect(result).not.toContain(TARGET)
expect(result).toContain('before rewrite')
expect(result).toContain('goal-before-rewrite')
expect(result).toContain('during rewrite')
expect(result).toContain('goal-during-rewrite')
expect(result.indexOf('during rewrite')).toBeLessThan(
result.indexOf('goal-during-rewrite'),
)
expect(result.indexOf('goal-before-rewrite')).toBeLessThan(
result.indexOf('during rewrite'),
)
})
test('a rewrite barrier is active as soon as hydration is enqueued', async () => {
const transcriptPath = await prepareHydration()
const remote = [message(KEEP_1, 'remote foreground')]
spyOn(sessionIngress, 'getSessionLogs').mockResolvedValue(remote as never)
setTranscriptRewriteHooksForTesting({
enqueued(filePath) {
if (filePath === transcriptPath) {
void saveCustomTitle(
SESSION_ID as UUID,
'queued after hydration barrier',
transcriptPath,
)
}
},
})
expect(await hydrateRemoteSession(SESSION_ID, 'https://ingress.test')).toBe(
true,
)
await flushSessionStorage()
const result = await readFile(transcriptPath, 'utf8')
expect(result).toContain('remote foreground')
expect(result).toContain('queued after hydration barrier')
expect(result.indexOf('remote foreground')).toBeLessThan(
result.indexOf('queued after hydration barrier'),
)
})
test('speculation acceptance is queued behind an active transcript rewrite', async () => {
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
const filePath = await useTranscript(original)
let release!: () => void
let paused!: () => void
const pausedPromise = new Promise<void>(resolve => {
paused = resolve
})
const releasePromise = new Promise<void>(resolve => {
release = resolve
})
let blocked = false
setAtomicReplaceFaultInjectorForTesting(async (stage, context) => {
if (!blocked && stage === 'stream-write' && context.requestedPath === filePath) {
blocked = true
paused()
await releasePromise
}
})
const removal = removeTranscriptMessage(TARGET)
await pausedPromise
await recordSpeculationAccept({
type: 'speculation-accept',
timestamp: TIMESTAMP,
timeSavedMs: 123,
})
release()
await removal
await flushSessionStorage()
const result = await readFile(filePath, 'utf8')
expect(result).not.toContain(TARGET)
expect(result).toContain('"type":"speculation-accept"')
expect(result).toContain('"timeSavedMs":123')
})
test('a failed queued append settles a following tombstone and releases its barrier', async () => {
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
const filePath = await useTranscript(original)
setTranscriptRewriteHooksForTesting({
beforeFileAppend(appendPath) {
if (appendPath === filePath) throw new Error('injected append failure')
},
})
await recordTranscript([
message('20000000-0000-4000-8000-000000000006' as UUID, 'queued first'),
])
const removal = removeTranscriptMessage(TARGET)
await expect(flushSessionStorage()).rejects.toThrow('injected append failure')
await removal
resetTranscriptRewriteHooksForTesting()
await saveCustomTitle(SESSION_ID as UUID, 'barrier released', filePath)
expect(await readFile(filePath, 'utf8')).toContain('barrier released')
})
test('a long rewrite keeps drains single-flight across late appends, tombstones, and flush', async () => {
const secondTarget = OTHER_SESSION_ID as UUID
const lateUuid = '20000000-0000-4000-8000-000000000005' as UUID
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(secondTarget)}\n${line(KEEP_2)}\n`
const filePath = await useTranscript(original)
let release!: () => void
let paused!: () => void
const pausedPromise = new Promise<void>(resolve => {
paused = resolve
})
const releasePromise = new Promise<void>(resolve => {
release = resolve
})
let blocked = false
setAtomicReplaceFaultInjectorForTesting(async (stage, context) => {
if (!blocked && stage === 'stream-write' && context.requestedPath === filePath) {
blocked = true
paused()
await releasePromise
}
})
const firstRemoval = removeTranscriptMessage(TARGET)
await pausedPromise
await recordTranscript([message(lateUuid, 'late queued append')])
const secondRemoval = removeTranscriptMessage(secondTarget)
release()
await Promise.all([firstRemoval, secondRemoval])
await flushSessionStorage()
const result = await readFile(filePath, 'utf8')
expect(result).not.toContain(TARGET)
expect(result).not.toContain(secondTarget)
expect(result).toContain(lateUuid)
expect(result).toContain('late queued append')
})
test('a microtask append at rewrite completion is not stranded behind the barrier', async () => {
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n`
const filePath = await useTranscript(original)
let queuedLateAppend!: () => void
const lateAppendQueued = new Promise<void>(resolve => {
queuedLateAppend = resolve
})
setTranscriptRewriteHooksForTesting({
beforeBarrierRelease(rewritePath) {
if (rewritePath !== filePath) return
queueMicrotask(() => {
void saveCustomTitle(
SESSION_ID as UUID,
'microtask at barrier release',
filePath,
)
queuedLateAppend()
})
},
})
await removeTranscriptMessage(TARGET)
await lateAppendQueued
await flushSessionStorage()
const result = await readFile(filePath, 'utf8')
expect(result).not.toContain(TARGET)
expect(result).toContain('microtask at barrier release')
})
test('two concurrent tombstones serialize without resurrecting either entry', async () => {
const original = `${line(KEEP_1)}\n${line(TARGET)}\n${line(KEEP_2)}\n${line(OTHER_SESSION_ID as UUID)}\n`
const filePath = await useTranscript(original)
await Promise.all([
removeTranscriptMessage(TARGET),
removeTranscriptMessage(OTHER_SESSION_ID as UUID),
])
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(`${line(KEEP_1)}\n${line(KEEP_2)}\n`)
})
test('slow duplicate removal preserves a missing final newline', async () => {
const longFinalTarget = line(TARGET, { payload: 'x'.repeat(70 * 1024) })
const original = `${line(TARGET)}\n${line(KEEP_1)}\n${longFinalTarget}`
const filePath = await useTranscript(original)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
expect(await readFile(filePath, 'utf8')).toBe(line(KEEP_1))
})
test('resume loader reads the byte-preserved tombstone result', async () => {
const first = { ...message(KEEP_1, 'keep one'), parentUuid: null }
const removed = { ...message(TARGET, 'remove me'), parentUuid: KEEP_1 }
const last = { ...message(KEEP_2, 'keep two'), parentUuid: KEEP_1 }
const original = `${JSON.stringify(first)}\n{malformed but preserved}\n${JSON.stringify(removed)}\n${JSON.stringify(last)}\n`
const filePath = await useTranscript(original)
await removeTranscriptMessage(TARGET)
await flushSessionStorage()
const loaded = await loadTranscriptFile(filePath, { keepAllLeaves: true })
expect(loaded.messages.has(KEEP_1)).toBe(true)
expect(loaded.messages.has(TARGET)).toBe(false)
expect(loaded.messages.has(KEEP_2)).toBe(true)
expect(loaded.messages.get(KEEP_2)?.parentUuid).toBe(KEEP_1)
expect(
buildConversationChain(loaded.messages, loaded.messages.get(KEEP_2)!).map(
entry => entry.uuid,
),
).toEqual([KEEP_1, KEEP_2])
expect(await readFile(filePath, 'utf8')).toContain('{malformed but preserved}\n')
})
test('v1 foreground hydration commits complete content and mode atomically', async () => {
const transcriptPath = await prepareHydration()
const remote = [message(KEEP_1, 'remote one'), message(KEEP_2, 'remote two')]
spyOn(sessionIngress, 'getSessionLogs').mockResolvedValue(remote as never)
expect(await hydrateRemoteSession(SESSION_ID, 'https://ingress.test')).toBe(true)
expect(await readFile(transcriptPath, 'utf8')).toBe(
`${remote.map(entry => JSON.stringify(entry)).join('\n')}\n`,
)
expect((await stat(transcriptPath)).mode & 0o777).toBe(0o600)
})
test('v1 null fetch, serialization failure, and rename failure preserve old content', async () => {
const transcriptPath = await prepareHydration()
const original = `${line(KEEP_1)}\n`
await writeFile(transcriptPath, original)
const getLogs = spyOn(sessionIngress, 'getSessionLogs')
getLogs.mockResolvedValueOnce(null)
expect(await hydrateRemoteSession(SESSION_ID, 'https://ingress.test')).toBe(false)
expect(await readFile(transcriptPath, 'utf8')).toBe(original)
const circular: Record<string, unknown> = { uuid: TARGET }
circular.self = circular
getLogs.mockResolvedValueOnce([circular] as never)
expect(await hydrateRemoteSession(SESSION_ID, 'https://ingress.test')).toBe(false)
expect(await readFile(transcriptPath, 'utf8')).toBe(original)
getLogs.mockResolvedValueOnce([message(KEEP_2, 'replacement')] as never)
setAtomicReplaceFaultInjectorForTesting((stage, context) => {
if (stage === 'rename' && context.requestedPath === transcriptPath) {
throw new Error('injected rename')
}
})
expect(await hydrateRemoteSession(SESSION_ID, 'https://ingress.test')).toBe(false)
expect(await readFile(transcriptPath, 'utf8')).toBe(original)
})
test('empty foreground hydration preserves an existing transcript', async () => {
const transcriptPath = await prepareHydration()
const original = `${line(KEEP_1)}\n`
await writeFile(transcriptPath, original)
spyOn(sessionIngress, 'getSessionLogs').mockResolvedValue([] as never)
expect(await hydrateRemoteSession(SESSION_ID, 'https://ingress.test')).toBe(false)
expect(await readFile(transcriptPath, 'utf8')).toBe(original)
resetProjectForTesting()
setInternalEventReader(async () => [], async () => [])
expect(await hydrateFromCCRv2InternalEvents(SESSION_ID)).toBe(false)
expect(await readFile(transcriptPath, 'utf8')).toBe(original)
})
test('CCR subagent transcripts commit independently and suppress full-success diagnostics', async () => {
const transcriptPath = await prepareHydration()
const diagnosticsPath = join(testRoot, 'diagnostics.jsonl')
process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = diagnosticsPath
const agentA = 'agent-a'
const agentB = 'agent-b'
setInternalEventReader(
async () => [{ payload: message(KEEP_1, 'foreground') as never }],
async () => [
{ agent_id: agentA, payload: { type: 'test', uuid: KEEP_1 } },
{ agent_id: agentB, payload: { type: 'test', uuid: KEEP_2 } },
],
)
const agentAPath = getAgentTranscriptPath(agentA as never)
const agentBPath = getAgentTranscriptPath(agentB as never)
await mkdir(dirname(agentAPath), { recursive: true, mode: 0o700 })
await writeFile(agentAPath, 'old-a\n')
await writeFile(agentBPath, 'old-b\n')
setAtomicReplaceFaultInjectorForTesting((stage, context) => {
if (stage === 'rename' && context.requestedPath === agentBPath) {
throw new Error('agent-b rename failed')
}
})
expect(await hydrateFromCCRv2InternalEvents(SESSION_ID)).toBe(false)
expect(await readFile(transcriptPath, 'utf8')).toContain('foreground')
expect(await readFile(agentAPath, 'utf8')).toContain(KEEP_1)
expect(await readFile(agentBPath, 'utf8')).toBe('old-b\n')
const diagnostics = await readFile(diagnosticsPath, 'utf8')
expect(diagnostics).not.toContain('hydrate_ccr_v2_completed')
expect(diagnostics).toContain('hydrate_ccr_v2_subagent_write_fail')
})
test('CCR distinguishes failed subagent fetch from a successful empty fetch', async () => {
const transcriptPath = await prepareHydration()
const diagnosticsPath = join(testRoot, 'subagent-read-diagnostics.jsonl')
process.env.CLAUDE_CODE_DIAGNOSTICS_FILE = diagnosticsPath
setInternalEventReader(
async () => [{ payload: message(KEEP_1, 'foreground') as never }],
async () => null,
)
expect(await hydrateFromCCRv2InternalEvents(SESSION_ID)).toBe(false)
expect(await readFile(transcriptPath, 'utf8')).toContain('foreground')
const failedDiagnostics = await readFile(diagnosticsPath, 'utf8')
expect(failedDiagnostics).toContain('hydrate_ccr_v2_subagent_read_fail')
expect(failedDiagnostics).not.toContain('hydrate_ccr_v2_completed')
resetProjectForTesting()
await writeFile(diagnosticsPath, '')
setInternalEventReader(
async () => [{ payload: message(KEEP_2, 'foreground') as never }],
async () => [],
)
expect(await hydrateFromCCRv2InternalEvents(SESSION_ID)).toBe(true)
expect(await readFile(diagnosticsPath, 'utf8')).toContain(
'hydrate_ccr_v2_completed',
)
})
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
import { lstatSync, readlinkSync } from 'node:fs'
import { lstat, readlink } from 'node:fs/promises'
import { dirname, isAbsolute, resolve } from 'node:path'
import { getErrnoCode } from './errors.js'
import * as lockfile from './lockfile.js'
const TRANSCRIPT_LOCK_STALE_MS = 30_000
const TRANSCRIPT_LOCK_WAIT_MS = 30_000
const syncWaitBuffer = new Int32Array(new SharedArrayBuffer(4))
const asyncHeldLockCounts = new Map<string, number>()
const syncHeldLockCounts = new Map<string, number>()
async function resolveTranscriptMutationTarget(
requestedPath: string,
): Promise<string> {
let currentPath = resolve(requestedPath)
const visited = new Set<string>()
for (let depth = 0; depth < 40; depth++) {
if (visited.has(currentPath)) {
throw new Error(`Cannot lock circular transcript symlink: ${requestedPath}`)
}
visited.add(currentPath)
let fileStat
try {
fileStat = await lstat(currentPath)
} catch (error) {
if (getErrnoCode(error) === 'ENOENT') return currentPath
throw error
}
if (!fileStat.isSymbolicLink()) return currentPath
const linkTarget = await readlink(currentPath)
currentPath = isAbsolute(linkTarget)
? linkTarget
: resolve(dirname(currentPath), linkTarget)
}
throw new Error(`Cannot lock transcript symlink chain: ${requestedPath}`)
}
function resolveTranscriptMutationTargetSync(requestedPath: string): string {
let currentPath = resolve(requestedPath)
const visited = new Set<string>()
for (let depth = 0; depth < 40; depth++) {
if (visited.has(currentPath)) {
throw new Error(`Cannot lock circular transcript symlink: ${requestedPath}`)
}
visited.add(currentPath)
let fileStat
try {
fileStat = lstatSync(currentPath)
} catch (error) {
if (getErrnoCode(error) === 'ENOENT') return currentPath
throw error
}
if (!fileStat.isSymbolicLink()) return currentPath
const linkTarget = readlinkSync(currentPath)
currentPath = isAbsolute(linkTarget)
? linkTarget
: resolve(dirname(currentPath), linkTarget)
}
throw new Error(`Cannot lock transcript symlink chain: ${requestedPath}`)
}
function incrementHeldLock(
counts: Map<string, number>,
targetPath: string,
): void {
counts.set(targetPath, (counts.get(targetPath) ?? 0) + 1)
}
function decrementHeldLock(
counts: Map<string, number>,
targetPath: string,
): void {
const remaining = (counts.get(targetPath) ?? 1) - 1
if (remaining === 0) counts.delete(targetPath)
else counts.set(targetPath, remaining)
}
function asyncLockOptions(
targetPath: string,
onCompromised: (error: Error) => void,
) {
return {
lockfilePath: `${targetPath}.lock`,
realpath: false,
stale: TRANSCRIPT_LOCK_STALE_MS,
update: 5_000,
retries: {
retries: 240,
factor: 1.1,
minTimeout: 5,
maxTimeout: 250,
randomize: true,
},
onCompromised,
}
}
function acquireTranscriptLockSync(targetPath: string): () => void {
const deadline = Date.now() + TRANSCRIPT_LOCK_WAIT_MS
let retryDelay = 5
while (true) {
try {
return lockfile.lockSync(targetPath, {
lockfilePath: `${targetPath}.lock`,
realpath: false,
stale: TRANSCRIPT_LOCK_STALE_MS,
update: 5_000,
})
} catch (error) {
if (getErrnoCode(error) !== 'ELOCKED' || Date.now() >= deadline) {
throw error
}
Atomics.wait(syncWaitBuffer, 0, 0, retryDelay)
retryDelay = Math.min(retryDelay * 2, 100)
}
}
}
/** Serialize a complete transcript mutation with writers in other processes. */
export async function withTranscriptFileLock<T>(
requestedPath: string,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const targetPath = await resolveTranscriptMutationTarget(requestedPath)
const controller = new AbortController()
const release = await lockfile.lock(
targetPath,
asyncLockOptions(targetPath, error => controller.abort(error)),
)
incrementHeldLock(asyncHeldLockCounts, targetPath)
try {
controller.signal.throwIfAborted()
return await operation(controller.signal)
} finally {
try {
try {
await release()
} catch (error) {
if (
!controller.signal.aborted ||
getErrnoCode(error) !== 'ERELEASED'
) {
throw error
}
}
} finally {
decrementHeldLock(asyncHeldLockCounts, targetPath)
}
}
}
/** Synchronous counterpart for shutdown and metadata append call sites. */
export function withTranscriptFileLockSync<T>(
requestedPath: string,
operation: () => T,
): T {
const targetPath = resolveTranscriptMutationTargetSync(requestedPath)
if ((syncHeldLockCounts.get(targetPath) ?? 0) > 0) return operation()
const release = acquireTranscriptLockSync(targetPath)
incrementHeldLock(syncHeldLockCounts, targetPath)
try {
return operation()
} finally {
try {
release()
} finally {
decrementHeldLock(syncHeldLockCounts, targetPath)
}
}
}
/** Return whether this process currently holds an async mutation lock. */
export function isTranscriptFileLockHeldByAsyncOperation(
requestedPath: string,
): boolean {
const targetPath = resolveTranscriptMutationTargetSync(requestedPath)
return (asyncHeldLockCounts.get(targetPath) ?? 0) > 0
}
/** @internal Verify exact lock coverage in deterministic concurrency tests. */
export async function isTranscriptFileLockHeldForTesting(
requestedPath: string,
): Promise<boolean> {
const targetPath = await resolveTranscriptMutationTarget(requestedPath)
return (
(asyncHeldLockCounts.get(targetPath) ?? 0) > 0 ||
(syncHeldLockCounts.get(targetPath) ?? 0) > 0
)
}