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 +}