fix(wsl-mover): refuse an unreadable slept checkpoint, prefer it, and spool it to disk

Review of #32343:
- A checkpoint behind an unreadable log (or an unreadable one) refuses with the pane named instead
  of carrying stale history.
- For a pane the move just slept the checkpoint is the newest copy, so it wins over an older
  renderer buffer or snapshot.
- Buffers spool to a per-move folder and only their hashes and sizes stay in memory; the snapshot
  channel reads them by offset. The folder is removed whatever the outcome.
- A journal write that landed before it threw is reset, so no orphan record blocks the next move.

awin's checkpoints were 310-615 KB, well inside the 5 MB per-pane cap, so nothing there is trimmed.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-23 04:04:46 -07:00
co-authored by Claude
parent 5c3801bb3d
commit fb90078675
9 changed files with 371 additions and 196 deletions
+11 -3
View File
@@ -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 () => {
@@ -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<string, Buffer>()
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 ||
@@ -140,7 +140,7 @@ export function exportRequestOf(
prepared: Pick<WslMovePrepared, 'plan' | 'host'>,
migrationId: string,
startedAt: Date,
sleptScrollback?: ReadonlyMap<string, Buffer>
sleptScrollback?: (tabId: string, leafId: string) => Buffer | null
): WslMoveManifestExportRequest {
const { plan, host } = prepared
return {
@@ -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<ReadonlyMap<string, Buffer>>
distro: string,
migrationId: string
) => Promise<WslMoveSleptScrollback>
shellHistory: (
distro: string,
worktrees: WslMoveManifestExport['worktrees']
@@ -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
@@ -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<string, Buffer>
slept: WslMoveSleptScrollback
): Promise<WslMoveResult> {
const { signal } = running.controller
const { distro } = prepared.plan
@@ -207,7 +224,9 @@ async function moveQuiesced(
const startedAt = new Date(quiesced.startedAt)
let again: ReturnType<WslMoveDriverDeps['exportManifest']>
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
}
@@ -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<string, Buffer>
/** 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)) {
@@ -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<WorkspaceSessionState['terminalLayoutsByTabId'][string]> = {}) {
// 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()
})
})
@@ -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<string, WslMoveSleptBuffer>
/** 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<WslMoveDriverDeps, 'sleptScrollback'>,
panes: readonly WslMoveRestoreHintPane[],
distro: string
): Promise<ReadonlyMap<string, Buffer>> {
distro: string,
migrationId: string
): Promise<WslMoveSleptScrollback> {
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<WslMoveSleptScrollback> {
const buffers = new Map<string, WslMoveSleptBuffer>()
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<string, Buffer>
slept: WslMoveSleptScrollback
): OrcadMigrationSnapshotSource {
if (slept.size === 0) {
if (slept.buffers.size === 0) {
return source
}
const byRef = new Map<string, { bytes: Buffer; sha256: string }>()
for (const [key, bytes] of slept) {
const byRef = new Map<string, WslMoveSleptBuffer>()
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<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
}