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
This commit is contained in:
Neil
2026-10-26 15:21:50 -07:00
committed by GitHub
parent d2f9617615
commit 39b390516e
2 changed files with 140 additions and 0 deletions
@@ -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<typeof DaemonHealthModule>()
return {
...actual,
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock
}
})
vi.mock('./daemon-tcc-attribution', async (importOriginal) => {
const actual = await importOriginal<typeof DaemonTccAttributionModule>()
return {
...actual,
getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock
}
})
/** Drives the adapter's own periodic save, which has no public trigger. */
class SavingDaemonPtyAdapter extends DaemonPtyAdapter {
savePeriodically(sessionIds: string[]): Promise<unknown> {
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<typeof createMockSubprocess>
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<void>((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()
})
})
@@ -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({