From 75e6d7516dc964bd35a091d12694ac038d84a2ad Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Sun, 23 Aug 2026 05:47:58 -0400 Subject: [PATCH] fix(native-chat): invalidate rewritten SSH ranges --- .../ssh-transcript-host-routing.test.ts | 56 +++++++++++++++++++ .../native-chat/ssh-transcript-range-fs.ts | 5 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/ssh-transcript-host-routing.test.ts b/src/main/native-chat/ssh-transcript-host-routing.test.ts index de16c2aba8c..440a0dd1bb7 100644 --- a/src/main/native-chat/ssh-transcript-host-routing.test.ts +++ b/src/main/native-chat/ssh-transcript-host-routing.test.ts @@ -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 => ({ + 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 => ({ + 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( diff --git a/src/main/native-chat/ssh-transcript-range-fs.ts b/src/main/native-chat/ssh-transcript-range-fs.ts index d9ce52be0a5..b180bcace3d 100644 --- a/src/main/native-chat/ssh-transcript-range-fs.ts +++ b/src/main/native-chat/ssh-transcript-range-fs.ts @@ -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() } }