mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user