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