fix(native-chat): invalidate rewritten SSH ranges

This commit is contained in:
Jinwoo-H
2026-08-23 14:35:56 -07:00
committed by Brennan Benson
parent a9ce7a2b48
commit 75e6d7516d
2 changed files with 60 additions and 1 deletions
@@ -45,6 +45,7 @@ import {
nativeChatLineDecoderForAgent,
readNativeChatTranscriptTail
} from './transcript-tail-reader'
import { createSshTranscriptRangeFs } from './ssh-transcript-range-fs'
import { getActiveNativeChatWatcherCount, subscribeNativeChatTranscript } from './transcript-watch'
import { readIncrementalTranscriptMessages } from './transcript-incremental-reader'
import { TranscriptRangeReadInvalidatedError, type TranscriptRangeFs } from './transcript-range-fs'
@@ -266,6 +267,61 @@ describe('native chat SSH transcript host routing (#13663)', () => {
expect(readFileRange.mock.calls.length).toBeGreaterThan(7)
})
it('invalidates a same-inode ranged snapshot rewritten in place', async () => {
const transcriptPath = '/tmp/same-inode-rewrite.jsonl'
const files = new Map([[transcriptPath, Buffer.from('old generation')]])
const remote = memoryProvider(files)
let mtimeMs = 1
remote.stat.mockImplementation(
async (filePath: string): Promise<FileStat> => ({
size: files.get(filePath)!.length,
type: 'file',
mtime: mtimeMs,
mtimeMs,
dev: 7,
ino: 11
})
)
mocks.getProvider.mockReturnValue(remote.provider)
const rangeFs = await createSshTranscriptRangeFs('ssh-owner')
const openingStamp = await rangeFs.stat(transcriptPath)
await rangeFs.read(transcriptPath, 0, 3)
files.set(transcriptPath, Buffer.from('new generation'))
mtimeMs = 2
await rangeFs.read(transcriptPath, 3, 3)
await expect(rangeFs.assertStable(transcriptPath, openingStamp)).rejects.toBeInstanceOf(
TranscriptRangeReadInvalidatedError
)
})
it('keeps a ranged snapshot valid across an ordinary append', async () => {
const transcriptPath = '/tmp/concurrent-append.jsonl'
const files = new Map([[transcriptPath, Buffer.from('opening')]])
const remote = memoryProvider(files)
let mtimeMs = 1
remote.stat.mockImplementation(
async (filePath: string): Promise<FileStat> => ({
size: files.get(filePath)!.length,
type: 'file',
mtime: mtimeMs,
mtimeMs,
dev: 7,
ino: 11
})
)
mocks.getProvider.mockReturnValue(remote.provider)
const rangeFs = await createSshTranscriptRangeFs('ssh-owner')
const openingStamp = await rangeFs.stat(transcriptPath)
await rangeFs.read(transcriptPath, 0, openingStamp.size)
files.set(transcriptPath, Buffer.from('opening append'))
mtimeMs = 2
await expect(rangeFs.assertStable(transcriptPath, openingStamp)).resolves.toBeUndefined()
})
it('withholds remote append batches and restores the cursor after invalidation', async () => {
const bytes = Buffer.from(
Array.from({ length: 50 }, (_, index) => claudeLine(`stale-${index}`, `stale ${index}`)).join(
@@ -84,7 +84,10 @@ export async function createSshTranscriptRangeFs(
const identityChanged = closingStamp.identity !== openingStamp.identity
const unpinnedVersionChanged =
!openingStamp.identityReliable && closingStamp.mtimeMs !== openingStamp.mtimeMs
if (identityChanged || unpinnedVersionChanged) {
const nonAppendSnapshotChanged =
closingStamp.size < openingStamp.size ||
(closingStamp.size === openingStamp.size && closingStamp.mtimeMs !== openingStamp.mtimeMs)
if (identityChanged || unpinnedVersionChanged || nonAppendSnapshotChanged) {
throw new TranscriptRangeReadInvalidatedError()
}
}