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 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-23 03:36:10 -07:00
co-authored by Claude
parent a5bdb61e93
commit 637fd933b2
12 changed files with 303 additions and 21 deletions
+3
View File
@@ -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 () => {
@@ -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)
@@ -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
)
}
@@ -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<string, Buffer>()
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 ||
@@ -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)
@@ -139,10 +139,12 @@ async function resetForUnregisteredDistro(
export function exportRequestOf(
prepared: Pick<WslMovePrepared, 'plan' | 'host'>,
migrationId: string,
startedAt: Date
startedAt: Date,
sleptScrollback?: ReadonlyMap<string, Buffer>
): WslMoveManifestExportRequest {
const { plan, host } = prepared
return {
...(sleptScrollback ? { sleptScrollback } : {}),
migrationId,
now: () => startedAt,
distro: plan.distro,
@@ -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<ReadonlyMap<string, Buffer>>
shellHistory: (
distro: string,
worktrees: WslMoveManifestExport['worktrees']
@@ -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<string, Buffer>
): Promise<WslMoveResult> {
const { signal } = running.controller
const { distro } = prepared.plan
@@ -198,7 +203,7 @@ async function moveQuiesced(
const startedAt = new Date(quiesced.startedAt)
let again: ReturnType<WslMoveDriverDeps['exportManifest']>
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)
@@ -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<string, Buffer>
}
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)) {
@@ -25,7 +25,7 @@ export type WslMoveRestoreHintCapture = {
staleCwdPaneKeys: string[]
}
type ColdRestoreReader = {
export type ColdRestoreReader = {
detectColdRestoreState: (
sessionId: string,
opts?: { ignoreCleanEnd?: boolean; wslDistro?: string }
@@ -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'
})
})
})
@@ -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<WslMoveDriverDeps, 'sleptScrollback'>,
panes: readonly WslMoveRestoreHintPane[],
distro: string
): Promise<ReadonlyMap<string, Buffer>> {
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<string, Buffer>
): OrcadMigrationSnapshotSource {
if (slept.size === 0) {
return source
}
const byRef = new Map<string, { bytes: Buffer; sha256: string }>()
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<Map<string, Buffer>> {
const buffers = new Map<string, Buffer>()
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
}