From 637fd933b2bc5f64fdc57ce2ef6c70e8ce301669 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 23 Oct 2026 03:33:19 -0700 Subject: [PATCH] fix(wsl-mover): carry a slept pane's scrollback from its daemon checkpoint The move sleeps every pane first, and a slept pane's history lives only in the daemon's checkpoint, never in a renderer snapshot. The export carried renderer snapshots only, so moved terminals opened empty. After Sleep the driver reads each paused pane's checkpoint (as cold restore would replay it) and the export carries it under the pane's own snapshot ref; the snapshot channel serves those bytes, checked against the signed hash. SSH conversions pass no reader and are unchanged. Co-Authored-By: Claude --- src/main/ipc/wsl-move-driver-wiring.ts | 3 + .../orcad-source-dormant-state.ts | 7 +- .../orcad-source-export.ts | 6 +- .../orcad-source-scrollback-state.ts | 27 +++- .../orcad-source-workspace-session.ts | 18 ++- .../wsl-move-driver-plan.ts | 4 +- .../wsl-move-driver-ports.ts | 8 ++ .../moving-wsl-projects/wsl-move-driver.ts | 19 ++- .../wsl-move-manifest-export.ts | 6 +- .../wsl-move-restore-hints.ts | 2 +- .../wsl-move-slept-scrollback.test.ts | 118 ++++++++++++++++++ .../wsl-move-slept-scrollback.ts | 106 ++++++++++++++++ 12 files changed, 303 insertions(+), 21 deletions(-) create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts diff --git a/src/main/ipc/wsl-move-driver-wiring.ts b/src/main/ipc/wsl-move-driver-wiring.ts index 4e695d51850..80587178415 100644 --- a/src/main/ipc/wsl-move-driver-wiring.ts +++ b/src/main/ipc/wsl-move-driver-wiring.ts @@ -39,6 +39,7 @@ import { parseWslGuestPaneProcesses, WSL_GUEST_PANE_PROCESS_SCRIPT } from '../persistence/moving-wsl-projects/wsl-move-guest-foreground' +import { readWslMoveSleptScrollbackFromHistory } from '../persistence/moving-wsl-projects/wsl-move-slept-scrollback' import { scheduleWslMoveRewriteRetry } from '../persistence/moving-wsl-projects/wsl-move-rewrite-retry' import { captureWslMoveRestoreHints } from '../persistence/moving-wsl-projects/wsl-move-restore-hints' import { @@ -151,6 +152,8 @@ export function createWslMoveDriverDeps( } }, snapshots: store, + sleptScrollback: (panes, distro) => + readWslMoveSleptScrollbackFromHistory(new HistoryReader(getDaemonHistoryDir()), panes, distro), shellHistory: (distro, worktrees) => buildWslMoveShellHistoryBlobs({ distro, worktrees }), // The window reload is the renderer's (`reloadRequired`), through restart preparation. finishOnDesktop: async () => { diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts index 2118e87e5ad..c8a683f9987 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-dormant-state.ts @@ -68,7 +68,9 @@ export function collectOrcadMigrationSourceDormantState( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, storage?: TerminalScrollbackSnapshotStorage, - destinationEnvironmentId?: string + destinationEnvironmentId?: string, + /** A WSL move's slept panes; see `projectOrcadMigrationSessionScrollback`. */ + sleptLeafScrollback?: (tabId: string, leafId: string) => Buffer | null ): OrcadMigrationSourceDormantInspection { const scope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos }) const blockedCounts = emptyBlockedCounts() @@ -180,7 +182,8 @@ export function collectOrcadMigrationSourceDormantState( source, catalog, storage, - leaveBehind + leaveBehind, + sleptLeafScrollback ) const blockedSessionItems = [...workspaceSession.blockedItems] const automationState = collectOrcadMigrationSourceAutomationState(state, source, catalog) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts index 8c112acbae5..696495938f2 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-export.ts @@ -69,7 +69,8 @@ export class OrcadSourceExportPersistence { inspectOrcadMigrationSourceDormantState( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, - destinationEnvironmentId?: string + destinationEnvironmentId?: string, + sleptLeafScrollback?: (tabId: string, leafId: string) => Buffer | null ): OrcadMigrationSourceDormantInspection { const runtime = this[orcadSourceExportContext] return collectOrcadMigrationSourceDormantState( @@ -77,7 +78,8 @@ export class OrcadSourceExportPersistence { source, catalog, runtime.terminalScrollbackSnapshotStorage, - destinationEnvironmentId + destinationEnvironmentId, + sleptLeafScrollback ) } diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts index d721e570670..7fbcbce7c82 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts @@ -5,6 +5,7 @@ import { MAX_ORCAD_MIGRATION_SCROLLBACK_TOTAL_BYTES } from '../../../shared/orcad-migration/orcad-migration-scrollback' import type { PersistedState } from '../../../shared/persisted-state-types' +import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree' import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { @@ -41,7 +42,12 @@ export function projectOrcadMigrationSessionScrollback( session: WorkspaceSessionState, storage?: TerminalScrollbackSnapshotStorage, /** "Move anyway": a pane's history that can't move stays on this desktop; the pane still moves. */ - leaveBehind = false + leaveBehind = false, + /** + * A WSL move's slept panes: their history is the daemon's checkpoint, never a renderer snapshot, + * so without this a moved terminal opens empty. Read only for a leaf with neither. + */ + sleptLeafScrollback?: (tabId: string, leafId: string) => Buffer | null ): ProjectedOrcadMigrationScrollback { const projected = structuredClone(session) const snapshots: OrcadMigrationTerminalScrollbackSnapshot[] = [] @@ -57,15 +63,26 @@ export function projectOrcadMigrationSessionScrollback( ...Object.keys(localOnly ?? {}), ...Object.keys(sourceLayout.scrollbackRefsByLeafId ?? {}) ]) + const slept = new Map() + if (sleptLeafScrollback) { + for (const leafId of collectLeafIds(sourceLayout.root)) { + const bytes = leafIds.has(leafId) ? null : sleptLeafScrollback(tabId, leafId) + if (bytes && bytes.length > 0) { + slept.set(leafId, bytes) + leafIds.add(leafId) + } + } + } for (const leafId of [...leafIds].sort(compareKeys)) { const buffer = inlineLeafBuffer(localOnly, sourceLayout.buffersByLeafId, leafId) const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId] - const ref = buffer ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef + const sleptBytes = slept.get(leafId) + const ref = + buffer || sleptBytes ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef const bytes = buffer ? Buffer.from(buffer, 'utf8') - : sourceRef - ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) - : null + : (sleptBytes ?? + (sourceRef ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) : null)) if ( !ref || !bytes || diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts index d7e4f86706a..e169353900b 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-workspace-session.ts @@ -63,11 +63,19 @@ export function collectOrcadMigrationSourceWorkspaceSession( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, storage?: TerminalScrollbackSnapshotStorage, - leaveBehind = false + leaveBehind = false, + sleptLeafScrollback?: (tabId: string, leafId: string) => Buffer | null ): OrcadMigrationSourceWorkspaceSessionInspection { let collected: OrcadMigrationSourceWorkspaceSessionInspection try { - collected = collectWorkspaceSession(state, source, catalog, storage, leaveBehind) + collected = collectWorkspaceSession( + state, + source, + catalog, + storage, + leaveBehind, + sleptLeafScrollback + ) } catch (error) { console.warn('[migration] Unreadable workspace session blocks the move:', error) collected = { @@ -98,7 +106,8 @@ function collectWorkspaceSession( source: OrcadMigrationManifestSource, catalog: OrcadMigrationCatalogPayload, storage: TerminalScrollbackSnapshotStorage | undefined, - leaveBehind: boolean + leaveBehind: boolean, + sleptLeafScrollback: ((tabId: string, leafId: string) => Buffer | null) | undefined ): OrcadMigrationSourceWorkspaceSessionInspection { const sourceScope = createOrcadMigrationExportScope({ source, catalog, repos: state.repos }) const fragments: SessionFragment[] = [] @@ -176,7 +185,8 @@ function collectWorkspaceSession( const projected = projectOrcadMigrationSessionScrollback( projectSessionToDestination(fragment, scope), storage, - leaveBehind + leaveBehind, + sleptLeafScrollback ) blockedItems.push(...projected.blockedItems) leftBehindItems.push(...projected.leftBehindItems) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts index d7618881750..54d6e7a63dc 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts @@ -139,10 +139,12 @@ async function resetForUnregisteredDistro( export function exportRequestOf( prepared: Pick, migrationId: string, - startedAt: Date + startedAt: Date, + sleptScrollback?: ReadonlyMap ): WslMoveManifestExportRequest { const { plan, host } = prepared return { + ...(sleptScrollback ? { sleptScrollback } : {}), migrationId, now: () => startedAt, distro: plan.distro, diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-ports.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-ports.ts index f6764f0186f..f9931a38b29 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-driver-ports.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-ports.ts @@ -84,6 +84,14 @@ export type WslMoveDriverDeps = { 'sleepWorkspaces' | 'livePtyIds' | 'captureRestoreHints' | 'proveExited' > snapshots: OrcadMigrationSnapshotSource + /** + * After Sleep, each paused pane's daemon checkpoint as the buffer a terminal restores from, keyed + * `tabId:leafId`. A slept pane has no renderer snapshot, so this is its only scrollback. + */ + sleptScrollback?: ( + panes: readonly WslMoveRestoreHintPane[], + distro: string + ) => Promise> shellHistory: ( distro: string, worktrees: WslMoveManifestExport['worktrees'] diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts index 0bfa61b697f..d6ef0a275a6 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts @@ -23,6 +23,7 @@ import { } from './wsl-move-cutover-journal' import { journalWslMove } from './wsl-move-delta-plan' import type { WslMoveDriverDeps, WslMoveHost } from './wsl-move-driver-ports' +import { readSleptScrollback, withSleptSnapshots } from './wsl-move-slept-scrollback' import { exportRequestOf, isWslMoveScopeChanged, @@ -120,10 +121,13 @@ async function runMove( return paused } const { panes } = paused + const slept = await readSleptScrollback(deps, panes, distro) let journaled: WslMoveCutover try { // The slept state, which the session freeze then holds for the rest of the move. - const exported = deps.exportManifest(exportRequestOf(prepared, migrationId, startedAt)) + const exported = deps.exportManifest( + exportRequestOf(prepared, migrationId, startedAt, slept) + ) if (exported.blockers.length > 0) { return refusal('wsl_move_export_incomplete', { reasons: [...exported.blockers] }) } @@ -173,7 +177,7 @@ async function runMove( : refusal('wsl_move_pause_failed', { detail: pauseDetail(quiesced.reason, quiesced.detail) }) } try { - return await moveQuiesced(deps, host, quiesced.cutover, prepared, running) + return await moveQuiesced(deps, host, quiesced.cutover, prepared, running, slept) } catch (error) { return putBack(deps, host, migrationId, describe(error)) } @@ -184,7 +188,8 @@ async function moveQuiesced( host: WslMoveHost, quiesced: WslMoveCutover, prepared: WslMovePrepared, - running: RunningWslMove + running: RunningWslMove, + slept: ReadonlyMap ): Promise { const { signal } = running.controller const { distro } = prepared.plan @@ -198,7 +203,7 @@ async function moveQuiesced( const startedAt = new Date(quiesced.startedAt) let again: ReturnType try { - again = deps.exportManifest(exportRequestOf(prepared, migrationId, startedAt)) + again = deps.exportManifest(exportRequestOf(prepared, migrationId, startedAt, slept)) } catch (error) { throw isWslMoveScopeChanged(error) ? new WslMoveStop('wsl_move_scope_changed') : error } @@ -213,7 +218,11 @@ async function moveQuiesced( } stopIfCancelled() deps.progress({ distro, step: 'copying', migrationId, phase: quiesced.phase }) - const staged = await stageWslMove(deps, host, quiesced) + const staged = await stageWslMove( + { ...deps, snapshots: withSleptSnapshots(deps.snapshots, slept) }, + host, + quiesced + ) stopIfCancelled() const blobs = await collectWslMoveBlobs(deps, quiesced, again.worktrees) const bytesTotal = blobs.descriptors.reduce((sum, blob) => sum + blob.byteLength, 0) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts index 727390ffe62..582057bef0a 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-manifest-export.ts @@ -47,6 +47,8 @@ export type WslMoveManifestExportRequest = { /** plan.repoIds: the exact scope. */ repoIds: readonly string[] folderWorkspaceIds: readonly string[] + /** Slept panes' daemon history, keyed `tabId:leafId`: their only copy of the scrollback. */ + sleptScrollback?: ReadonlyMap } export type WslMoveManifestExport = { @@ -133,10 +135,12 @@ export function exportWslMoveManifest( scopeFolderWorkspaceIds: [...request.folderWorkspaceIds], generation: request.generation } + const slept = request.sleptScrollback const inspection = store.inspectOrcadMigrationSourceDormantState( source, catalog, - request.destinationEnvironmentId + request.destinationEnvironmentId, + slept ? (tabId, leafId) => slept.get(`${tabId}:${leafId}`) ?? null : undefined ) blockers.push(...inspection.blockedSessionItems, ...inspection.leftBehindItems) for (const [kind, count] of Object.entries(inspection.blockedCounts)) { diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts index e5ed6bbc817..7c74d204e5e 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-restore-hints.ts @@ -25,7 +25,7 @@ export type WslMoveRestoreHintCapture = { staleCwdPaneKeys: string[] } -type ColdRestoreReader = { +export type ColdRestoreReader = { detectColdRestoreState: ( sessionId: string, opts?: { ignoreCleanEnd?: boolean; wslDistro?: string } diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts new file mode 100644 index 00000000000..135cbb66aba --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts @@ -0,0 +1,118 @@ +import { createHash } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import type { OrcadMigrationManifest } from '../../../shared/orcad-migration/orcad-migration-manifest' +import { makeTerminalScrollbackSnapshotRef } from '../../terminal-scrollback-snapshots' +import { projectOrcadMigrationSessionScrollback } from '../migrating-orcad-catalog/orcad-source-scrollback-state' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + readWslMoveSleptScrollbackFromHistory, + withSleptSnapshots +} from './wsl-move-slept-scrollback' + +const TAB = 'feature-shell' +const LEAF = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB}:${LEAF}` + +function sleptSession(): WorkspaceSessionState { + // What Sleep leaves: the layout names the pane, but no renderer buffer or snapshot ref. + return { + ...getDefaultWorkspaceSession(), + terminalLayoutsByTabId: { + [TAB]: { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF, expandedLeafId: null } + } + } +} + +describe('a slept pane on the snapshot channel', () => { + it('carries the daemon checkpoint a slept pane has instead of a renderer snapshot', () => { + const history = Buffer.from('$ make\r\nok\r\n$ ', 'utf8') + const without = projectOrcadMigrationSessionScrollback(sleptSession()) + // Before the fix the moved terminal opened empty: nothing was carried. + expect(without.snapshots).toEqual([]) + const projected = projectOrcadMigrationSessionScrollback( + sleptSession(), + undefined, + false, + (tabId, leafId) => (tabId === TAB && leafId === LEAF ? history : null) + ) + const ref = makeTerminalScrollbackSnapshotRef(TAB, LEAF) + expect(projected.blockedItems).toEqual([]) + expect(projected.snapshots).toEqual([ + { + tabId: TAB, + leafId: LEAF, + ref, + sha256: createHash('sha256').update(history).digest('hex'), + byteLength: history.length + } + ]) + expect(projected.session.terminalLayoutsByTabId[TAB]?.scrollbackRefsByLeafId).toEqual({ + [LEAF]: ref + }) + }) + + it('serves those bytes to the host, and leaves any other ref to the store', () => { + const history = Buffer.from('x'.repeat(10), 'utf8') + const ref = makeTerminalScrollbackSnapshotRef(TAB, LEAF) + const manifestWith = (sha256: string): OrcadMigrationManifest => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader reads only these fields. + ({ + payload: { + dormantState: { + terminalScrollbackSnapshots: [ + { tabId: TAB, leafId: LEAF, ref, sha256, byteLength: history.length } + ] + } + } + }) as unknown as OrcadMigrationManifest + const store = { readOrcadMigrationSourceSnapshotChunk: vi.fn(() => ({ bytesBase64: '', totalBytes: 0, eof: true })) } + const source = withSleptSnapshots(store, new Map([[PANE_KEY, history]])) + const signed = manifestWith(createHash('sha256').update(history).digest('hex')) + expect(source.readOrcadMigrationSourceSnapshotChunk(signed, ref, 4)).toEqual({ + bytesBase64: Buffer.from('xxxxxx').toString('base64'), + totalBytes: 10, + eof: true + }) + expect(store.readOrcadMigrationSourceSnapshotChunk).not.toHaveBeenCalled() + // A renderer snapshot signed for the same ref is the store's to serve. + source.readOrcadMigrationSourceSnapshotChunk(manifestWith('0'.repeat(64)), ref, 0) + expect(store.readOrcadMigrationSourceSnapshotChunk).toHaveBeenCalledTimes(1) + }) + + it('reads the checkpoint a sleep left, scrollback first, keyed by the pane', async () => { + const reader = { + detectColdRestoreState: vi.fn(async (sessionId: string) => + sessionId === 'pty-1' + ? { + status: 'restored' as const, + sessionId, + hasUnreadableRecovery: false, + restoreInfo: { + scrollbackAnsi: 'old\r\n', + snapshotAnsi: '$ ', + rehydrateSequences: '', + cwd: '/home/me', + cols: 80, + rows: 24, + modes: {} as never + } + } + : { status: 'none' as const } + ) + } + const buffers = await readWslMoveSleptScrollbackFromHistory( + reader, + [ + { paneKey: PANE_KEY, sessionId: 'pty-1', wslDistro: 'Ubuntu' }, + { paneKey: 'other:22222222-1111-4111-8111-111111111111', sessionId: 'pty-2' } + ], + 'Ubuntu' + ) + expect([...buffers]).toEqual([[PANE_KEY, Buffer.from('old\r\n$ ', 'utf8')]]) + expect(reader.detectColdRestoreState).toHaveBeenCalledWith('pty-1', { + ignoreCleanEnd: true, + wslDistro: 'Ubuntu' + }) + }) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts new file mode 100644 index 00000000000..ec12e29e269 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts @@ -0,0 +1,106 @@ +/** + * A slept pane's scrollback lives only in the daemon's checkpoint, never in a renderer snapshot, so a + * WSL move reads it after Sleep and carries it on the snapshot channel under the pane's own ref. + */ +import { createHash } from 'node:crypto' +import { ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES } from '../../../shared/orcad-migration/orcad-migration-scrollback' +import type { OrcadMigrationSnapshotSource } from '../../ssh/orcad-migration-snapshot-coordinator' +import { parsePaneKey } from '../../../shared/stable-pane-id' +import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits' +import { + decodeFromFirstUtf8Boundary, + makeTerminalScrollbackSnapshotRef +} from '../../terminal-scrollback-snapshots' +import type { WslMoveDriverDeps } from './wsl-move-driver-ports' +import type { ColdRestoreReader, WslMoveRestoreHintPane } from './wsl-move-restore-hints' + +/** Never throws: a pane whose checkpoint can't be read moves without its history, as before. */ +export async function readSleptScrollback( + deps: Pick, + panes: readonly WslMoveRestoreHintPane[], + distro: string +): Promise> { + if (!deps.sleptScrollback) { + return new Map() + } + return deps.sleptScrollback(panes, distro).catch((error: unknown) => { + console.warn('[wsl-move] Slept terminal history unreadable:', error) + return new Map() + }) +} + +/** The store's snapshot reader, answering a slept pane's ref from the buffer the manifest signed. */ +export function withSleptSnapshots( + source: OrcadMigrationSnapshotSource, + slept: ReadonlyMap +): OrcadMigrationSnapshotSource { + if (slept.size === 0) { + return source + } + const byRef = new Map() + for (const [key, bytes] of slept) { + const split = key.lastIndexOf(':') + byRef.set(makeTerminalScrollbackSnapshotRef(key.slice(0, split), key.slice(split + 1)), { + bytes, + sha256: createHash('sha256').update(bytes).digest('hex') + }) + } + return { + readOrcadMigrationSourceSnapshotChunk: (manifest, ref, offset) => { + const descriptor = manifest.payload.dormantState?.terminalScrollbackSnapshots?.find( + (entry) => entry.ref === ref + ) + const candidate = descriptor ? byRef.get(ref) : undefined + // A leaf with its own renderer snapshot signed that one; the store serves it. + const bytes = candidate?.sha256 === descriptor?.sha256 ? candidate?.bytes : undefined + if (!bytes) { + return source.readOrcadMigrationSourceSnapshotChunk(manifest, ref, offset) + } + const chunk = bytes.subarray(offset, offset + ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES) + return { + bytesBase64: chunk.toString('base64'), + totalBytes: bytes.length, + eof: offset + chunk.length >= bytes.length + } + } + } +} + +/** + * Production reader: each pane's checkpoint, as cold restore would replay it (scrollback, then the + * screen), kept to the newest bytes one stored snapshot holds. + */ +export async function readWslMoveSleptScrollbackFromHistory( + reader: ColdRestoreReader, + panes: readonly WslMoveRestoreHintPane[], + distro: string +): Promise> { + const buffers = new Map() + for (const pane of panes) { + const leaf = parsePaneKey(pane.paneKey) + if (!leaf) { + continue + } + // A sleep ends the session cleanly; its checkpoint is exactly what a cold restore would read. + const detection = await reader + .detectColdRestoreState(pane.sessionId, { ignoreCleanEnd: true, wslDistro: distro }) + .catch(() => null) + if (detection?.status !== 'restored') { + continue + } + const { scrollbackAnsi, snapshotAnsi } = detection.restoreInfo + const bytes = Buffer.from(`${scrollbackAnsi}${snapshotAnsi}`, 'utf8') + if (bytes.length > 0) { + buffers.set( + `${leaf.tabId}:${leaf.leafId}`, + bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + ? Buffer.from( + decodeFromFirstUtf8Boundary(bytes.subarray(-TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT)), + 'utf8' + ) + : bytes + ) + } + } + return buffers +}