From f65afb045f0879afe49dac6207c7d8b88b72f106 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:20:11 -0700 Subject: [PATCH] fix(daemon): hand off slept v29 history to v30 (#11423) Co-authored-by: Orca --- src/main/daemon/daemon-history-handoff.ts | 15 ++ .../daemon-pty-router-history-handoff.test.ts | 129 ++++++++++++++++++ src/main/daemon/daemon-pty-router.test.ts | 47 +++++++ src/main/daemon/daemon-pty-router.ts | 20 +-- 4 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 src/main/daemon/daemon-history-handoff.ts create mode 100644 src/main/daemon/daemon-pty-router-history-handoff.test.ts diff --git a/src/main/daemon/daemon-history-handoff.ts b/src/main/daemon/daemon-history-handoff.ts new file mode 100644 index 00000000000..f797006f184 --- /dev/null +++ b/src/main/daemon/daemon-history-handoff.ts @@ -0,0 +1,15 @@ +import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' + +export function shouldHandoffDaemonHistory( + keepHistory: boolean | undefined, + owner: DaemonPtyAdapter, + current: DaemonPtyAdapter +): boolean { + return ( + keepHistory === true && + owner !== current && + owner.protocolVersion < HISTORY_SEED_TRANSFER_PROTOCOL_VERSION && + current.protocolVersion >= HISTORY_SEED_TRANSFER_PROTOCOL_VERSION + ) +} diff --git a/src/main/daemon/daemon-pty-router-history-handoff.test.ts b/src/main/daemon/daemon-pty-router-history-handoff.test.ts new file mode 100644 index 00000000000..a53a4b29b63 --- /dev/null +++ b/src/main/daemon/daemon-pty-router-history-handoff.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DaemonPtyAdapter } from './daemon-pty-adapter' +import { DaemonPtyRouter } from './daemon-pty-router' +import { DaemonServer } from './daemon-server' +import { getDaemonSocketPath } from './daemon-spawner' +import { TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS } from './terminal-history-seed-chunks' +import type { DaemonFileLog } from './daemon-file-log' +import type { SubprocessHandle } from './session' + +function createMockSubprocess(): SubprocessHandle & { + emitData: (data: string) => void +} { + let onData: ((data: string) => void) | undefined + let onExit: ((code: number) => void) | undefined + return { + pid: 999_999_999, + getForegroundProcess: () => null, + write: vi.fn(), + resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + kill: vi.fn(() => setTimeout(() => onExit?.(0), 0)), + forceKill: vi.fn(() => setTimeout(() => onExit?.(137), 0)), + signal: vi.fn(), + onData(callback) { + onData = callback + }, + onExit(callback) { + onExit = callback + }, + dispose: vi.fn(), + emitData(data: string) { + onData?.(data) + } + } +} + +describe('DaemonPtyRouter history handoff', () => { + let testDir: string + let historyPath: string + let legacyServer: DaemonServer + let currentServer: DaemonServer + let legacyAdapter: DaemonPtyAdapter + let currentAdapter: DaemonPtyAdapter + let router: DaemonPtyRouter + let legacySubprocess: ReturnType + + beforeEach(async () => { + testDir = mkdtempSync(join(tmpdir(), 'daemon-router-history-handoff-')) + historyPath = join(testDir, 'history') + const log: DaemonFileLog = { log: () => {}, close: () => {} } + legacyServer = new DaemonServer({ + socketPath: getDaemonSocketPath(testDir, 29), + tokenPath: join(testDir, 'legacy.token'), + protocolVersion: 29, + log, + spawnSubprocess: () => { + legacySubprocess = createMockSubprocess() + return legacySubprocess + } + }) + currentServer = new DaemonServer({ + socketPath: getDaemonSocketPath(testDir, 30), + tokenPath: join(testDir, 'current.token'), + protocolVersion: 30, + log, + spawnSubprocess: createMockSubprocess + }) + await Promise.all([legacyServer.start(), currentServer.start()]) + legacyAdapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(testDir, 29), + tokenPath: join(testDir, 'legacy.token'), + protocolVersion: 29, + historyPath + }) + currentAdapter = new DaemonPtyAdapter({ + socketPath: getDaemonSocketPath(testDir, 30), + tokenPath: join(testDir, 'current.token'), + protocolVersion: 30, + historyPath + }) + }) + + afterEach(async () => { + if (router) { + router.dispose() + } else { + legacyAdapter?.dispose() + currentAdapter?.dispose() + } + await Promise.all([legacyServer?.shutdown(), currentServer?.shutdown()]) + rmSync(testDir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('moves a slept v29 session to v30 with its full large history', async () => { + const sessionId = 'large-legacy-session' + const marker = 'V29-HISTORY-HANDOFF-MARKER' + await legacyAdapter.spawn({ sessionId, cols: 400, rows: 24 }) + legacySubprocess.emitData(`${'x'.repeat(TERMINAL_HISTORY_INLINE_SEED_CODE_UNITS + 1)}${marker}`) + router = new DaemonPtyRouter({ current: currentAdapter, legacy: [legacyAdapter] }) + await router.discoverLegacySessions() + const client = ( + currentAdapter as unknown as { + client: { request: (type: string, payload?: unknown) => Promise } + } + ).client + const request = vi.spyOn(client, 'request') + + await router.shutdown(sessionId, { immediate: true, keepHistory: true }) + const restored = await router.spawn({ sessionId, cols: 400, rows: 24 }) + + expect(restored.coldRestore?.scrollback).toContain(marker) + expect(request.mock.calls.map(([type]) => type)).toEqual( + expect.arrayContaining([ + 'startHistorySeedTransfer', + 'appendHistorySeedTransfer', + 'finishHistorySeedTransfer' + ]) + ) + await expect(router.getBufferSnapshot(sessionId)).resolves.toMatchObject({ + data: expect.stringContaining(marker) + }) + expect(currentAdapter.getHistoryManager()?.hasWriter(sessionId)).toBe(true) + }) +}) diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 10792800171..46088a23ac7 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -7,6 +7,7 @@ import { AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION, GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION } from './types' +import { HISTORY_SEED_TRANSFER_PROTOCOL_VERSION } from './daemon-protocol-version' type AdapterMock = DaemonPtyAdapter & { emitData: (id: string, data: string, sequenceChars?: number) => void @@ -470,6 +471,52 @@ describe('DaemonPtyRouter', () => { expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session') }) + it('hands a checkpointed pre-v30 session to the current daemon on wake', async () => { + const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) + const legacy = createAdapter( + 'legacy', + ['legacy-session'], + undefined, + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION - 1 + ) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + await router.shutdown('legacy-session', { keepHistory: true }) + await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + + expect(legacy.shutdown).toHaveBeenCalledWith('legacy-session', { keepHistory: true }) + expect(legacy.ackColdRestore).toHaveBeenCalledWith('legacy-session') + expect(current.spawn).toHaveBeenCalledWith({ + sessionId: 'legacy-session', + cols: 80, + rows: 24 + }) + expect(legacy.spawn).not.toHaveBeenCalled() + }) + + it('keeps the legacy route when checkpointed shutdown fails', async () => { + const current = createAdapter('current', [], undefined, HISTORY_SEED_TRANSFER_PROTOCOL_VERSION) + const legacy = createAdapter( + 'legacy', + ['legacy-session'], + undefined, + HISTORY_SEED_TRANSFER_PROTOCOL_VERSION - 1 + ) + vi.mocked(legacy.shutdown).mockRejectedValueOnce(new Error('checkpoint failed')) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + await expect(router.shutdown('legacy-session', { keepHistory: true })).rejects.toThrow( + 'checkpoint failed' + ) + await router.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + + expect(legacy.spawn).toHaveBeenCalled() + expect(legacy.ackColdRestore).not.toHaveBeenCalled() + expect(current.spawn).not.toHaveBeenCalled() + }) + it('fails listProcesses closed when any routed adapter cannot list sessions', async () => { const current = createAdapter('current', ['current-session']) const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index c81da83411d..e2919c450c9 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -10,6 +10,7 @@ import type { } from '../providers/types' import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtyProcessInspection } from '../providers/pty-process-inspection' +import { shouldHandoffDaemonHistory } from './daemon-history-handoff' export class DaemonPtyRouter implements IPtyProvider { private current: DaemonPtyAdapter @@ -132,15 +133,16 @@ export class DaemonPtyRouter implements IPtyProvider { id: string, opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number } ): Promise { - await this.adapterFor(id).shutdown(id, opts) - // Why: sleep passes keepHistory=true and re-spawns against the same - // sessionId on wake. If we delete the routing entry here, adapterFor() - // falls back to `this.current` on wake — for a session that originally - // lived on a legacy adapter (different protocolVersion), the wake-side - // createOrAttach lands on the wrong adapter and creates a fresh session, - // losing the cold-restore from the legacy adapter's history dir. - if (!opts.keepHistory) { - this.sessionAdapters.delete(id) + const adapter = this.adapterFor(id) + const migrateHistory = shouldHandoffDaemonHistory(opts.keepHistory, adapter, this.current) + await adapter.shutdown(id, opts) + if (!opts.keepHistory || migrateHistory) { + if (migrateHistory) { + adapter.ackColdRestore(id) + } + if (this.sessionAdapters.get(id) === adapter) { + this.sessionAdapters.delete(id) + } } }