diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 1bf43b40c05..20af546e06c 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -2273,6 +2273,7 @@ export class DaemonPtyAdapter implements IPtyProvider { opts?.requiredPreviousPendingOutputSeq !== undefined && restoreInfo?.pendingOutputSeq !== opts.requiredPreviousPendingOutputSeq ) { + console.warn('[history] durable continuity unproven; using live snapshot:', sessionId) return liveSnapshot } return await buildDurableCheckpointSnapshot({ diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index 7ce4742530d..ad1d4300eaf 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -60,6 +60,15 @@ function createMockSubprocess(): SubprocessHandle & { } } +function simulateAdapterCrash(adapter: DaemonPtyAdapter): void { + const internals = adapter as unknown as { + client: { disconnect: () => void } + stopCheckpointTimer: () => void + } + internals.stopCheckpointTimer() + internals.client.disconnect() +} + describe('STA-4091 previously recoverable restore depth', () => { it('live daemon memory still drops older rows so session count cannot grow unbounded', async () => { const subprocess = createMockSubprocess() @@ -363,6 +372,81 @@ describe('STA-4091 previously recoverable restore depth', () => { expect(snapshotText(restore ?? {})).toContain(OLDEST_WRITTEN_LINE) }) + it('preserves durable depth after an incremental append and adapter crash', async () => { + const { id } = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'incremental-crash-depth', + cwd: '/tmp' + }) + lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)) + await adapter.getBufferSnapshot(id) + lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`) + + const oldInternals = adapter as unknown as { checkpointDirtySessions: () => Promise } + await oldInternals.checkpointDirtySessions() + const beforeCrash = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(beforeCrash?.pendingOutputSeq).toBe(2) + expect(snapshotText(beforeCrash ?? {})).toContain(OLDEST_WRITTEN_LINE) + + simulateAdapterCrash(adapter) + adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(dir), + tokenPath: join(dir, 'test.token'), + historyPath: historyDir + }) + + const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' }) + expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT) + expect(reattach.snapshot).toContain(OLDEST_WRITTEN_LINE) + const restored = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(snapshotText(restored ?? {})).toContain(OLDEST_WRITTEN_LINE) + }) + + it('uses the live window when a crashed adapter left a pending-output gap', async () => { + const { id } = await adapter.spawn({ + cols: 80, + rows: 24, + sessionId: 'incremental-gap-depth', + cwd: '/tmp' + }) + lastSubprocess.emitData(numberedOutput(DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)) + await adapter.getBufferSnapshot(id) + lastSubprocess.emitData(`${FRESH_AFTER_CHECKPOINT}\r\n`) + + const oldInternals = adapter as unknown as { + client: { request: (method: string, params: unknown) => Promise } + } + await oldInternals.client.request('takePendingOutput', { + sessionId: id, + includeSnapshot: false + }) + simulateAdapterCrash(adapter) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + adapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(dir), + tokenPath: join(dir, 'test.token'), + historyPath: historyDir + }) + + const reattach = await adapter.spawn({ cols: 80, rows: 24, sessionId: id, cwd: '/tmp' }) + expect(reattach.snapshot).toContain(FRESH_AFTER_CHECKPOINT) + expect(reattach.snapshot).not.toContain(OLDEST_WRITTEN_LINE) + const restored = await new HistoryReader(historyDir).detectColdRestore(id, { + ignoreCleanEnd: true + }) + expect(snapshotText(restored ?? {})).not.toContain(OLDEST_WRITTEN_LINE) + expect(restored?.scrollbackLines).toBe(DAEMON_SESSION_SCROLLBACK_ROWS) + expect(warn).toHaveBeenCalledWith( + '[history] durable continuity unproven; using live snapshot:', + id + ) + }) + it('falls back to the live window when durable history cannot be read', async () => { const { id } = await adapter.spawn({ cols: 80, diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index e7e2d8ce1bb..de0a98e3071 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -317,9 +317,10 @@ export class HistoryReader { } } const snapshot = emulator.getSnapshot() + const lastBatch = log.batches.at(-1)! return { restoreInfo: coldRestoreInfoFromSnapshot( - snapshot, + { ...snapshot, pendingOutputSeq: lastBatch.seq }, snapshot.cwd ?? checkpoint?.cwd ?? meta.cwd, meta ),