diff --git a/src/main/ipc/wsl-move-driver-wiring.ts b/src/main/ipc/wsl-move-driver-wiring.ts index 80587178415..5dedf9d5ea6 100644 --- a/src/main/ipc/wsl-move-driver-wiring.ts +++ b/src/main/ipc/wsl-move-driver-wiring.ts @@ -39,7 +39,10 @@ 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 { + readWslMoveSleptScrollbackFromHistory, + wslMoveSleptSpoolRoot +} 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 { @@ -152,8 +155,13 @@ export function createWslMoveDriverDeps( } }, snapshots: store, - sleptScrollback: (panes, distro) => - readWslMoveSleptScrollbackFromHistory(new HistoryReader(getDaemonHistoryDir()), panes, distro), + sleptScrollback: (panes, distro, migrationId) => + readWslMoveSleptScrollbackFromHistory( + new HistoryReader(getDaemonHistoryDir()), + panes, + distro, + wslMoveSleptSpoolRoot(userDataPath, migrationId) + ), 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-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts index 7fbcbce7c82..ada996c0e99 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 @@ -45,7 +45,7 @@ export function projectOrcadMigrationSessionScrollback( 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. + * so without this a moved terminal opens empty. Taken first: the move slept the pane just now. */ sleptLeafScrollback?: (tabId: string, leafId: string) => Buffer | null ): ProjectedOrcadMigrationScrollback { @@ -66,7 +66,7 @@ export function projectOrcadMigrationSessionScrollback( const slept = new Map() if (sleptLeafScrollback) { for (const leafId of collectLeafIds(sourceLayout.root)) { - const bytes = leafIds.has(leafId) ? null : sleptLeafScrollback(tabId, leafId) + const bytes = sleptLeafScrollback(tabId, leafId) if (bytes && bytes.length > 0) { slept.set(leafId, bytes) leafIds.add(leafId) @@ -74,15 +74,21 @@ export function projectOrcadMigrationSessionScrollback( } } for (const leafId of [...leafIds].sort(compareKeys)) { - const buffer = inlineLeafBuffer(localOnly, sourceLayout.buffersByLeafId, leafId) - const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId] + // The move just slept this pane: its checkpoint is newer than any snapshot saved before. const sleptBytes = slept.get(leafId) + const buffer = sleptBytes + ? null + : inlineLeafBuffer(localOnly, sourceLayout.buffersByLeafId, leafId) + const sourceRef = sourceLayout.scrollbackRefsByLeafId?.[leafId] const ref = buffer || sleptBytes ? makeTerminalScrollbackSnapshotRef(tabId, leafId) : sourceRef - const bytes = buffer - ? Buffer.from(buffer, 'utf8') - : (sleptBytes ?? - (sourceRef ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) : null)) + const bytes = + sleptBytes ?? + (buffer + ? Buffer.from(buffer, 'utf8') + : sourceRef + ? readTerminalScrollbackStoredBytesSync(sourceRef, storage) + : null) if ( !ref || !bytes || 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 54d6e7a63dc..0f9fe2bb2dd 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 @@ -140,7 +140,7 @@ export function exportRequestOf( prepared: Pick, migrationId: string, startedAt: Date, - sleptScrollback?: ReadonlyMap + sleptScrollback?: (tabId: string, leafId: string) => Buffer | null ): WslMoveManifestExportRequest { const { plan, host } = prepared return { 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 f9931a38b29..abf8789c490 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 @@ -19,6 +19,7 @@ import type { OrcadMigrationDestinationCatalog } from '../../ssh/orcad-migration import type { OrcadMigrationSnapshotSource } from '../../ssh/orcad-migration-snapshot-coordinator' import type { WslMoveHostHolding } from './wsl-move-delta-plan' import type { WslMoveQuiesceDeps } from './wsl-move-quiesce' +import type { WslMoveSleptScrollback } from './wsl-move-slept-scrollback' import type { WslMoveRestoreHintPane } from './wsl-move-restore-hints' import type { WslMoveManifestExport, @@ -85,13 +86,14 @@ export type WslMoveDriverDeps = { > 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. + * After Sleep, each paused pane's daemon checkpoint as the buffer a terminal restores from. A + * slept pane has no renderer snapshot, so this is its only scrollback. */ sleptScrollback?: ( panes: readonly WslMoveRestoreHintPane[], - distro: string - ) => Promise> + distro: string, + migrationId: string + ) => Promise shellHistory: ( distro: string, worktrees: WslMoveManifestExport['worktrees'] diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver.test.ts index 29562f9c325..4c0708a9d8d 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-driver.test.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver.test.ts @@ -196,6 +196,24 @@ describe('moveProjectsToWslHost', () => { expect(journal()).toEqual([]) }) + it("refuses rather than move a slept pane whose history it can't fully read", async () => { + const dispose = vi.fn() + world.deps.sleptScrollback = async () => ({ + buffers: new Map(), + problems: ['saved terminal history Orca could not fully read for tab t1'], + dispose + }) + const result = await moveProjectsToWslHost(world.deps, request) + + expect(result).toMatchObject({ + outcome: 'refused', + code: 'wsl_move_export_incomplete', + reasons: ['saved terminal history Orca could not fully read for tab t1'] + }) + expect(journal()).toEqual([]) + expect(dispose).toHaveBeenCalledTimes(1) + }) + it('refuses a second move of the same distro while one runs', async () => { let release: () => void = () => {} const sleep = world.deps.quiesce.sleepWorkspaces 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 f518247273d..8093ffade9d 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver.ts @@ -23,7 +23,12 @@ 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 { + readSleptScrollback, + sleptScrollbackReader, + withSleptSnapshots, + type WslMoveSleptScrollback +} from './wsl-move-slept-scrollback' import { exportRequestOf, isWslMoveScopeChanged, @@ -121,69 +126,81 @@ async function runMove( return paused } const { panes } = paused - const slept = await readSleptScrollback(deps, panes, distro) - let journaled: WslMoveCutover + const slept = await readSleptScrollback(deps, panes, distro, migrationId) try { - // The slept state, which the session freeze then holds for the rest of the move. - const exported = deps.exportManifest( - exportRequestOf(prepared, migrationId, startedAt, slept) + if (slept.problems.length > 0) { + return refusal('wsl_move_export_incomplete', { reasons: [...slept.problems] }) + } + 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, sleptScrollbackReader(slept)) + ) + if (exported.blockers.length > 0) { + return refusal('wsl_move_export_incomplete', { reasons: [...exported.blockers] }) + } + journaled = journalWslMove({ + userDataPath: deps.userDataPath, + plan: prepared.plan, + manifest: exported.manifest, + destinationName: host.name, + automationsToReenable: exported.automationsToReenable, + now: () => startedAt + }) + } catch (error) { + // A write that landed before it threw would leave a record no host ever saw: drop it. + await resetWslMoveToZero( + { userDataPath: deps.userDataPath, destination: null }, + migrationId + ).catch(() => {}) + const tooLarge = manifestTooLargeRefusal(error) + if (tooLarge) { + return tooLarge + } + if (describe(error) === 'wsl_move_already_in_flight') { + return refusal('wsl_move_already_in_flight') + } + if (isWslMoveScopeChanged(error)) { + return refusal('wsl_move_scope_changed') + } + // Nothing is journaled yet, so nothing needs putting back; say why instead of a bare reset. + console.warn('[wsl-move] Could not prepare the move:', error) + return refusal('wsl_move_export_incomplete', { + reasons: [`Orca could not prepare these projects' saved state (${describe(error)}).`] + }) + } + const quiesced = await quiesceWslMove( + journaled, + { workspaceIds: scope.workspaceIds, workspaceKeys: scope.workspaceKeys, panes }, + { + ...deps.quiesce, + gateInputs: () => deps.gateInputs(scope), + writeCutover: (cutover) => writeWslMoveCutover(deps.userDataPath, cutover), + removeCutover: (id) => removeWslMoveCutover(deps.userDataPath, id), + now: deps.now + } ) - if (exported.blockers.length > 0) { - return refusal('wsl_move_export_incomplete', { reasons: [...exported.blockers] }) + if (quiesced.outcome === 'refused') { + // A refusal before the fence leaves the `prepared` record; nothing reached the host. + await resetWslMoveToZero( + { userDataPath: deps.userDataPath, destination: null }, + migrationId + ).catch(() => {}) + if (!quiesced.fenceLifted) { + return putBack(deps, host, migrationId, `pause:${quiesced.reason}`) + } + return quiesced.reason === 'terminals-blocked' + ? refusal('wsl_move_terminals_blocked', { blockers: quiesced.blockers ?? [] }) + : refusal('wsl_move_pause_failed', { detail: pauseDetail(quiesced.reason, quiesced.detail) }) } - journaled = journalWslMove({ - userDataPath: deps.userDataPath, - plan: prepared.plan, - manifest: exported.manifest, - destinationName: host.name, - automationsToReenable: exported.automationsToReenable, - now: () => startedAt - }) - } catch (error) { - const tooLarge = manifestTooLargeRefusal(error) - if (tooLarge) { - return tooLarge + try { + return await moveQuiesced(deps, host, quiesced.cutover, prepared, running, slept) + } catch (error) { + return putBack(deps, host, migrationId, describe(error)) } - if (describe(error) === 'wsl_move_already_in_flight') { - return refusal('wsl_move_already_in_flight') - } - if (isWslMoveScopeChanged(error)) { - return refusal('wsl_move_scope_changed') - } - // Nothing is journaled yet, so nothing needs putting back; say why instead of a bare reset. - console.warn('[wsl-move] Could not prepare the move:', error) - return refusal('wsl_move_export_incomplete', { - reasons: [`Orca could not prepare these projects' saved state (${describe(error)}).`] - }) - } - const quiesced = await quiesceWslMove( - journaled, - { workspaceIds: scope.workspaceIds, workspaceKeys: scope.workspaceKeys, panes }, - { - ...deps.quiesce, - gateInputs: () => deps.gateInputs(scope), - writeCutover: (cutover) => writeWslMoveCutover(deps.userDataPath, cutover), - removeCutover: (id) => removeWslMoveCutover(deps.userDataPath, id), - now: deps.now - } - ) - if (quiesced.outcome === 'refused') { - // A refusal before the fence leaves the `prepared` record; nothing reached the host. - await resetWslMoveToZero( - { userDataPath: deps.userDataPath, destination: null }, - migrationId - ).catch(() => {}) - if (!quiesced.fenceLifted) { - return putBack(deps, host, migrationId, `pause:${quiesced.reason}`) - } - return quiesced.reason === 'terminals-blocked' - ? refusal('wsl_move_terminals_blocked', { blockers: quiesced.blockers ?? [] }) - : refusal('wsl_move_pause_failed', { detail: pauseDetail(quiesced.reason, quiesced.detail) }) - } - try { - return await moveQuiesced(deps, host, quiesced.cutover, prepared, running, slept) - } catch (error) { - return putBack(deps, host, migrationId, describe(error)) + } finally { + slept.dispose() } } @@ -193,7 +210,7 @@ async function moveQuiesced( quiesced: WslMoveCutover, prepared: WslMovePrepared, running: RunningWslMove, - slept: ReadonlyMap + slept: WslMoveSleptScrollback ): Promise { const { signal } = running.controller const { distro } = prepared.plan @@ -207,7 +224,9 @@ async function moveQuiesced( const startedAt = new Date(quiesced.startedAt) let again: ReturnType try { - again = deps.exportManifest(exportRequestOf(prepared, migrationId, startedAt, slept)) + again = deps.exportManifest( + exportRequestOf(prepared, migrationId, startedAt, sleptScrollbackReader(slept)) + ) } catch (error) { throw isWslMoveScopeChanged(error) ? new WslMoveStop('wsl_move_scope_changed') : error } 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 582057bef0a..85c59f7fb1c 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,8 +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 + /** Slept panes' daemon history: their only copy of the scrollback. */ + sleptScrollback?: (tabId: string, leafId: string) => Buffer | null } export type WslMoveManifestExport = { @@ -135,12 +135,11 @@ export function exportWslMoveManifest( scopeFolderWorkspaceIds: [...request.folderWorkspaceIds], generation: request.generation } - const slept = request.sleptScrollback const inspection = store.inspectOrcadMigrationSourceDormantState( source, catalog, request.destinationEnvironmentId, - slept ? (tabId, leafId) => slept.get(`${tabId}:${leafId}`) ?? null : undefined + request.sleptScrollback ) 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-slept-scrollback.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.test.ts index 135cbb66aba..39bc8099f50 100644 --- 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 @@ -1,40 +1,76 @@ import { createHash } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, 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 { makeTerminalScrollbackSnapshotRef } from '../../terminal-scrollback-snapshots' +import { projectOrcadMigrationSessionScrollback } from '../migrating-orcad-catalog/orcad-source-scrollback-state' import { readWslMoveSleptScrollbackFromHistory, + sleptScrollbackReader, withSleptSnapshots } from './wsl-move-slept-scrollback' const TAB = 'feature-shell' const LEAF = '11111111-1111-4111-8111-111111111111' const PANE_KEY = `${TAB}:${LEAF}` +const roots: string[] = [] -function sleptSession(): WorkspaceSessionState { - // What Sleep leaves: the layout names the pane, but no renderer buffer or snapshot ref. +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } +}) + +function spoolRoot(): string { + const root = mkdtempSync(join(tmpdir(), 'wsl-move-slept-')) + roots.push(root) + return join(root, 'move-1') +} + +function sleptSession(extra: Partial = {}) { + // What Sleep leaves: the layout names the pane, with at most an older renderer buffer. return { ...getDefaultWorkspaceSession(), terminalLayoutsByTabId: { - [TAB]: { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF, expandedLeafId: null } + [TAB]: { + root: { type: 'leaf' as const, leafId: LEAF }, + activeLeafId: LEAF, + expandedLeafId: null, + ...extra + } + } + } satisfies WorkspaceSessionState +} + +function restored(scrollbackAnsi: string, snapshotAnsi: string, hasUnreadableRecovery = false) { + return { + status: 'restored' as const, + sessionId: 'pty-1', + hasUnreadableRecovery, + restoreInfo: { + scrollbackAnsi, + snapshotAnsi, + rehydrateSequences: '', + cwd: '/home/me', + cols: 80, + rows: 24, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: unread here. + modes: {} as never } } } describe('a slept pane on the snapshot channel', () => { - it('carries the daemon checkpoint a slept pane has instead of a renderer snapshot', () => { + it('carries the 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) + expect(projectOrcadMigrationSessionScrollback(sleptSession()).snapshots).toEqual([]) + const projected = projectOrcadMigrationSessionScrollback(sleptSession(), undefined, false, () => + history ) const ref = makeTerminalScrollbackSnapshotRef(TAB, LEAF) expect(projected.blockedItems).toEqual([]) @@ -52,67 +88,88 @@ describe('a slept pane on the snapshot channel', () => { }) }) - it('serves those bytes to the host, and leaves any other ref to the store', () => { - const history = Buffer.from('x'.repeat(10), 'utf8') + it('prefers the checkpoint the move just took over an older renderer buffer', () => { + const history = Buffer.from('newer', 'utf8') + const projected = projectOrcadMigrationSessionScrollback( + sleptSession({ buffersByLeafId: { [LEAF]: 'older' } }), + undefined, + false, + () => history + ) + expect(projected.snapshots[0]?.byteLength).toBe(history.length) + }) + + it('spools each buffer, serves it by ref, and leaves any other ref to the store', async () => { + const reader = { + detectColdRestoreState: vi.fn(async () => restored('old\r\n', '$ ')) + } + const slept = await readWslMoveSleptScrollbackFromHistory( + reader, + [{ paneKey: PANE_KEY, sessionId: 'pty-1', wslDistro: 'Ubuntu' }], + 'Ubuntu', + spoolRoot() + ) + expect(reader.detectColdRestoreState).toHaveBeenCalledWith('pty-1', { + ignoreCleanEnd: true, + wslDistro: 'Ubuntu' + }) + const bytes = sleptScrollbackReader(slept)?.(TAB, LEAF) + expect(bytes?.toString('utf8')).toBe('old\r\n$ ') 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 } - ] + terminalScrollbackSnapshots: [{ tabId: TAB, leafId: LEAF, ref, sha256, byteLength: 7 }] } } }) 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, + const store = { + readOrcadMigrationSourceSnapshotChunk: vi.fn(() => ({ + bytesBase64: '', + totalBytes: 0, + eof: true + })) + } + const source = withSleptSnapshots(store, slept) + const signed = manifestWith(createHash('sha256').update(bytes!).digest('hex')) + expect(source.readOrcadMigrationSourceSnapshotChunk(signed, ref, 3)).toEqual({ + bytesBase64: Buffer.from('\r\n$ ').toString('base64'), + totalBytes: 7, 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) + const path = slept.buffers.get(PANE_KEY)?.path + expect(path && existsSync(path)).toBe(true) + slept.dispose() + expect(path && existsSync(path)).toBe(false) }) - it('reads the checkpoint a sleep left, scrollback first, keyed by the pane', async () => { + it('reports a checkpoint it cannot fully read instead of carrying stale history', 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 } + ? restored('old\r\n', '$ ', true) + : { status: 'unreadable' as const, sessionId } ) } - const buffers = await readWslMoveSleptScrollbackFromHistory( + const slept = await readWslMoveSleptScrollbackFromHistory( reader, [ - { paneKey: PANE_KEY, sessionId: 'pty-1', wslDistro: 'Ubuntu' }, + { paneKey: PANE_KEY, sessionId: 'pty-1' }, { paneKey: 'other:22222222-1111-4111-8111-111111111111', sessionId: 'pty-2' } ], - 'Ubuntu' + 'Ubuntu', + spoolRoot() ) - expect([...buffers]).toEqual([[PANE_KEY, Buffer.from('old\r\n$ ', 'utf8')]]) - expect(reader.detectColdRestoreState).toHaveBeenCalledWith('pty-1', { - ignoreCleanEnd: true, - wslDistro: 'Ubuntu' - }) + expect(slept.buffers.size).toBe(0) + expect(slept.problems).toEqual([ + `saved terminal history Orca could not fully read for tab ${TAB}`, + 'saved terminal history Orca could not fully read for tab other' + ]) + slept.dispose() }) }) 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 index ec12e29e269..1e269bf64d3 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-slept-scrollback.ts @@ -1,12 +1,15 @@ /** - * 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. + * A slept pane's scrollback lives only in the daemon's checkpoint, never in a renderer snapshot, so + * a WSL move reads it right after its Sleep and carries it on the snapshot channel under the pane's + * own ref. Each buffer is spooled to a file, so only hashes and sizes stay in memory. */ import { createHash } from 'node:crypto' +import { closeSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' 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 type { OrcadMigrationSnapshotSource } from '../../ssh/orcad-migration-snapshot-coordinator' import { decodeFromFirstUtf8Boundary, makeTerminalScrollbackSnapshotRef @@ -14,93 +17,156 @@ import { 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 type WslMoveSleptBuffer = { path: string; sha256: string; byteLength: number } + +export type WslMoveSleptScrollback = { + /** Keyed `tabId:leafId`. */ + buffers: ReadonlyMap + /** User-facing: panes whose latest history can't be read. The move refuses rather than drop it. */ + problems: readonly string[] + /** Removes the spooled files; the move calls it whatever its outcome. */ + dispose: () => void +} + +const SPOOL_DIR = 'wsl-move-slept-scrollback' + +export function emptyWslMoveSleptScrollback(): WslMoveSleptScrollback { + return { buffers: new Map(), problems: [], dispose: () => {} } +} + +/** Never throws: an unreadable reader is a problem the move reports, not a crash. */ export async function readSleptScrollback( deps: Pick, panes: readonly WslMoveRestoreHintPane[], - distro: string -): Promise> { + distro: string, + migrationId: string +): Promise { if (!deps.sleptScrollback) { - return new Map() + return emptyWslMoveSleptScrollback() } - return deps.sleptScrollback(panes, distro).catch((error: unknown) => { + return deps.sleptScrollback(panes, distro, migrationId).catch((error: unknown) => { console.warn('[wsl-move] Slept terminal history unreadable:', error) - return new Map() + return { + ...emptyWslMoveSleptScrollback(), + problems: ['saved terminal history Orca could not read'] + } }) } -/** The store's snapshot reader, answering a slept pane's ref from the buffer the manifest signed. */ +/** For the export: a slept pane's buffer, read from its spool file only while it is hashed. */ +export function sleptScrollbackReader( + slept: WslMoveSleptScrollback +): ((tabId: string, leafId: string) => Buffer | null) | undefined { + if (slept.buffers.size === 0) { + return undefined + } + return (tabId, leafId) => { + const entry = slept.buffers.get(`${tabId}:${leafId}`) + return entry ? readFileSync(entry.path) : null + } +} + +/** + * 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. Sleep ends a session cleanly, which + * is exactly why its clean end is not a reason to skip the checkpoint here. + */ +export async function readWslMoveSleptScrollbackFromHistory( + reader: ColdRestoreReader, + panes: readonly WslMoveRestoreHintPane[], + distro: string, + spoolRoot: string +): Promise { + const buffers = new Map() + const problems: string[] = [] + const dispose = (): void => rmSync(spoolRoot, { recursive: true, force: true }) + try { + mkdirSync(spoolRoot, { recursive: true, mode: 0o700 }) + for (const [index, pane] of panes.entries()) { + const leaf = parsePaneKey(pane.paneKey) + if (!leaf) { + continue + } + const detection = await reader + .detectColdRestoreState(pane.sessionId, { ignoreCleanEnd: true, wslDistro: distro }) + .catch(() => ({ status: 'unreadable' as const, sessionId: pane.sessionId })) + if (detection.status === 'none') { + continue + } + // A checkpoint behind an unreadable log is older than the pane: moving it would lose the rest. + if (detection.status === 'unreadable' || detection.hasUnreadableRecovery) { + problems.push(`saved terminal history Orca could not fully read for tab ${leaf.tabId}`) + continue + } + const { scrollbackAnsi, snapshotAnsi } = detection.restoreInfo + const bytes = capped(Buffer.from(`${scrollbackAnsi}${snapshotAnsi}`, 'utf8')) + if (bytes.length === 0) { + continue + } + const path = join(spoolRoot, `${index}.bin`) + writeFileSync(path, bytes, { mode: 0o600 }) + buffers.set(`${leaf.tabId}:${leaf.leafId}`, { + path, + sha256: createHash('sha256').update(bytes).digest('hex'), + byteLength: bytes.length + }) + } + } catch (error) { + dispose() + throw error + } + return { buffers, problems, dispose } +} + +export function wslMoveSleptSpoolRoot(userDataPath: string, migrationId: string): string { + return join(userDataPath, SPOOL_DIR, migrationId) +} + +function capped(bytes: Buffer): Buffer { + return bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + ? Buffer.from( + decodeFromFirstUtf8Boundary(bytes.subarray(-TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT)), + 'utf8' + ) + : bytes +} + +/** The store's snapshot reader, answering a slept pane's ref from its spool file. */ export function withSleptSnapshots( source: OrcadMigrationSnapshotSource, - slept: ReadonlyMap + slept: WslMoveSleptScrollback ): OrcadMigrationSnapshotSource { - if (slept.size === 0) { + if (slept.buffers.size === 0) { return source } - const byRef = new Map() - for (const [key, bytes] of slept) { + const byRef = new Map() + for (const [key, entry] of slept.buffers) { const split = key.lastIndexOf(':') - byRef.set(makeTerminalScrollbackSnapshotRef(key.slice(0, split), key.slice(split + 1)), { - bytes, - sha256: createHash('sha256').update(bytes).digest('hex') - }) + byRef.set(makeTerminalScrollbackSnapshotRef(key.slice(0, split), key.slice(split + 1)), entry) } 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) { + const entry = byRef.get(ref) + // A ref the manifest signed from the store (not this spool) is the store's to serve. + if (!descriptor || !entry || entry.sha256 !== descriptor.sha256) { return source.readOrcadMigrationSourceSnapshotChunk(manifest, ref, offset) } - const chunk = bytes.subarray(offset, offset + ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES) + const length = Math.max(0, Math.min(ORCAD_MIGRATION_SCROLLBACK_CHUNK_BYTES, entry.byteLength - offset)) + const chunk = Buffer.alloc(length) + const fd = openSync(entry.path, 'r') + try { + readSync(fd, chunk, 0, length, offset) + } finally { + closeSync(fd) + } return { bytesBase64: chunk.toString('base64'), - totalBytes: bytes.length, - eof: offset + chunk.length >= bytes.length + totalBytes: entry.byteLength, + eof: offset + length >= entry.byteLength } } } } - -/** - * 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 -}