From 39b390516e37abd8f25290f050a09ee340e0540a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:21:50 -0700 Subject: [PATCH] fix(terminal): save a woken terminal's first history as a full snapshot (#34060) * fix(terminal): save a woken terminal's first history as a full snapshot A wake that reused the Sleep-time cold-restore cache reopened the history writer without asking for a full checkpoint, so the new process's output was appended to the pre-wake checkpoint, which lacks the fresh-shell padding the seed gave the daemon. The next save that replayed checkpoint + log (quit's final save, or a relaunch's adoption) printed the new shell's prompt on the restored one's row (`$ $`). Mark a full checkpoint for a new process in that path, as the uncached cold-restore path already does. * test(terminal): drive the periodic save through a test subclass instead of a cast --- ...-pty-adapter-wake-relaunch-history.test.ts | 134 ++++++++++++++++++ src/main/daemon/daemon-pty-spawn-result.ts | 6 + 2 files changed, 140 insertions(+) create mode 100644 src/main/daemon/daemon-pty-adapter-wake-relaunch-history.test.ts diff --git a/src/main/daemon/daemon-pty-adapter-wake-relaunch-history.test.ts b/src/main/daemon/daemon-pty-adapter-wake-relaunch-history.test.ts new file mode 100644 index 00000000000..86caaeb20c0 --- /dev/null +++ b/src/main/daemon/daemon-pty-adapter-wake-relaunch-history.test.ts @@ -0,0 +1,134 @@ +import './mock-descendant-sweep' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { readFileSync, rmSync } from 'node:fs' +import { Terminal } from '@xterm/headless' +import { parsePtySessionId } from '../../shared/pty-session-id-format' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import type { DaemonServer } from './daemon-server' +import { getHistorySessionDirName } from './history-paths' +import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness' +import type * as DaemonHealthModule from './daemon-health' +import type * as DaemonTccAttributionModule from './daemon-tcc-attribution' + +const { getMacDaemonSystemResolverHealthMock, getMacDaemonTccAttributionHealthMock } = vi.hoisted( + () => ({ + getMacDaemonSystemResolverHealthMock: vi.fn( + async (): Promise<'unknown' | 'unhealthy'> => 'unknown' + ), + getMacDaemonTccAttributionHealthMock: vi.fn( + async (): Promise<'intact' | 'severed' | 'unknown'> => 'unknown' + ) + }) +) + +vi.mock('./daemon-health', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock + } +}) + +vi.mock('./daemon-tcc-attribution', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock + } +}) + +/** Drives the adapter's own periodic save, which has no public trigger. */ +class SavingDaemonPtyAdapter extends DaemonPtyAdapter { + savePeriodically(sessionIds: string[]): Promise { + return this.checkpointSessions(sessionIds, { final: false }) + } +} + +describe('daemon history across a wake and a relaunch', () => { + let dir: string + let socketPath: string + let tokenPath: string + let server: DaemonServer + let adapter: DaemonPtyAdapter + let lastSubprocess: ReturnType + + beforeEach(async () => { + const harness = await startDaemonAdapterHarness(() => { + lastSubprocess = createMockSubprocess() + return lastSubprocess + }) + dir = harness.dir + socketPath = harness.socketPath + tokenPath = harness.tokenPath + server = harness.server + adapter = harness.adapter + getMacDaemonSystemResolverHealthMock.mockReset() + getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown') + getMacDaemonTccAttributionHealthMock.mockReset() + getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown') + }) + + afterEach(async () => { + adapter?.dispose() + await server?.shutdown() + rmSync(dir, { recursive: true, force: true }) + }) + // awin verify-15a: Sleep, Wake, quit and relaunch, then Sleep put the new prompt on the old one. + it('keeps the fresh-shell padding in history after a wake, periodic saves and a relaunch', async () => { + const historyDir = join(dir, 'history') + const prompt = 'user@host:~/project$ ' + const sessionId = 'wt-1::/project@@wake1' + const checkpointRows = async () => { + const saved = JSON.parse( + readFileSync( + join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), + 'utf8' + ) + ) + const terminal = new Terminal({ + cols: 47, + rows: 44, + scrollback: 5000, + allowProposedApi: true + }) + await new Promise((resolve) => terminal.write(saved.snapshotAnsi, resolve)) + const rows: string[] = [] + for (let i = 0; i < terminal.buffer.active.length; i++) { + rows.push(terminal.buffer.active.getLine(i)?.translateToString(true).trimEnd() ?? '') + } + terminal.dispose() + return rows + } + let history = new SavingDaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const save = (adapter: SavingDaemonPtyAdapter) => adapter.savePeriodically([sessionId]) + + await history.spawn({ cols: 47, rows: 44, cwd: '/project', sessionId }) + lastSubprocess._simulateData(`${prompt}echo BEFORE\r\nBEFORE\r\n${prompt}`) + await history.shutdown(sessionId, { immediate: true, keepHistory: true }) + await history.spawn({ cols: 47, rows: 44, cwd: '/project', sessionId }) + history.ackColdRestore(sessionId) + lastSubprocess._simulateData(`${prompt}echo AFTER\r\nAFTER\r\n${prompt}`) + await save(history) + await history.disconnectOnly() + + history = new SavingDaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + await history.reconcileOnStartup(new Set([parsePtySessionId(sessionId).worktreeId ?? ''])) + await save(history) + + const rows = await checkpointRows() + const restoredPrompt = rows.indexOf('BEFORE') + 1 + expect(rows[restoredPrompt]).toBe(prompt.trimEnd()) + expect(rows[restoredPrompt + 1]).toBe('') + expect(rows.filter((row) => row.split(prompt.trimEnd()).length > 2)).toEqual([]) + expect(rows.filter((row) => row !== '')).toEqual([ + `${prompt}echo BEFORE`, + 'BEFORE', + prompt.trimEnd(), + `${prompt}echo AFTER`, + 'AFTER', + prompt.trimEnd() + ]) + history.dispose() + }) +}) diff --git a/src/main/daemon/daemon-pty-spawn-result.ts b/src/main/daemon/daemon-pty-spawn-result.ts index eef5702b774..ac2d888fdfd 100644 --- a/src/main/daemon/daemon-pty-spawn-result.ts +++ b/src/main/daemon/daemon-pty-spawn-result.ts @@ -113,6 +113,12 @@ export abstract class DaemonPtySpawnResult extends DaemonPtySpawnRequest { this.historyManager.suspendSession(sessionId, recoveryFreeze) } else { this.historyManager.reopenSession(sessionId, recoveryFreeze) + if (result.isNew) { + // A new process seeded with the fresh-shell padding: its output must not append to a + // checkpoint that lacks that padding, or a later replay prints its prompt on the old one. + this.sessionsNeedingFullCheckpoint.add(sessionId) + this.lastFullCheckpointAt.delete(sessionId) + } } } return finalizeSpawnResult({