mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 11:07:42 +00:00
test(wsl-mover): kill the real move driver at every point against a real host store; abort a reset move's leftover stage on reconnect
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* A WSL host for the move driver backed by a real orcad profile store, so staging claims, blob
|
||||
* staging, the commit's blob check and receipts are the production code. Every mutation passes a
|
||||
* kill point before it lands and after it is durable (a lost reply). A kill stops the whole desktop
|
||||
* process: from then on every call throws, so no catch handler can tidy up what a crash would leave.
|
||||
*/
|
||||
import type {
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
} from '../../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { ORCAD_MIGRATION_BLOB_KIND_CONTRACTS } from '../../../../shared/orcad-migration/orcad-migration-blob-transfer'
|
||||
import {
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY
|
||||
} from '../../../../shared/orcad-runtime-capabilities'
|
||||
import type { Store } from '../../loading-store/store'
|
||||
import type { WslMoveHost } from '../../moving-wsl-projects/wsl-move-driver-ports'
|
||||
|
||||
export class WslMoveKilled extends Error {}
|
||||
|
||||
export type WslMoveKillSwitch = {
|
||||
/** The point the next kill fires at; null runs clean. */
|
||||
killAt: string | null
|
||||
/** True from the kill until the test restarts the process. */
|
||||
dead: boolean
|
||||
points: string[]
|
||||
}
|
||||
|
||||
export function createWslMoveKillSwitch(): WslMoveKillSwitch {
|
||||
return { killAt: null, dead: false, points: [] }
|
||||
}
|
||||
|
||||
export function passWslMoveKillPoint(kill: WslMoveKillSwitch, name: string): void {
|
||||
if (kill.dead) {
|
||||
throw new WslMoveKilled(`dead at ${name}`)
|
||||
}
|
||||
kill.points.push(name)
|
||||
if (kill.killAt === name) {
|
||||
kill.killAt = null
|
||||
kill.dead = true
|
||||
throw new WslMoveKilled(name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every capability a current WSL host advertises for a move. */
|
||||
export const WSL_MOVE_STORE_HOST_CAPABILITIES: readonly string[] = [
|
||||
ORCAD_MIGRATION_LOCAL_WSL_SOURCE_RUNTIME_CAPABILITY,
|
||||
ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY,
|
||||
...Object.values(ORCAD_MIGRATION_BLOB_KIND_CONTRACTS).map((contract) => contract.capability)
|
||||
]
|
||||
|
||||
export type WslMoveStoreHostOptions = {
|
||||
/** The host's current store; an orcad restart swaps it for one reopened from disk. */
|
||||
store: () => Store
|
||||
kill: WslMoveKillSwitch
|
||||
/** Points named `<prefix>stage:before` etc., so two desktops on one host stay apart. */
|
||||
prefix?: string
|
||||
environmentId?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export function createWslMoveStoreHost(options: WslMoveStoreHostOptions): WslMoveHost {
|
||||
const { store, kill } = options
|
||||
const prefix = options.prefix ?? ''
|
||||
const at = (name: string): void => passWslMoveKillPoint(kill, `${prefix}${name}`)
|
||||
// Like the RPC layer: a mutation answers only once it is on disk.
|
||||
const mutate = async <T>(name: string, apply: () => T): Promise<T> => {
|
||||
at(`${name}:before`)
|
||||
const result = apply()
|
||||
await store().flushPendingOrThrowAsync()
|
||||
at(`${name}:lost-reply`)
|
||||
return result
|
||||
}
|
||||
const read = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => {
|
||||
at('read-state')
|
||||
return store().getOrcadMigrationCatalogState(manifest)
|
||||
}
|
||||
return {
|
||||
environmentId: options.environmentId ?? 'wsl:Ubuntu',
|
||||
name: options.name ?? 'Ubuntu',
|
||||
capabilities: WSL_MOVE_STORE_HOST_CAPABILITIES,
|
||||
catalog: {
|
||||
readState: async (manifest) => read(manifest),
|
||||
stage: (manifest) => mutate('stage', () => store().stageOrcadMigrationCatalog(manifest)),
|
||||
commit: (manifest) =>
|
||||
mutate('commit', () => store().commitStagedOrcadMigrationCatalog(manifest)),
|
||||
abort: async (manifest) => ({
|
||||
...(await mutate('abort', () => store().abortStagedOrcadMigrationCatalog(manifest))),
|
||||
durableAbsent: true
|
||||
}),
|
||||
abortByReference: (manifest) =>
|
||||
mutate('abort-by-reference', () =>
|
||||
store().abortStagedOrcadMigrationCatalogByReference({
|
||||
migrationId: manifest.migrationId,
|
||||
manifestSha256: manifest.manifestSha256
|
||||
})
|
||||
),
|
||||
stageChunk: (request) =>
|
||||
mutate('snapshot-chunk', () => store().stageOrcadMigrationSnapshotChunk(request))
|
||||
},
|
||||
commitWithBlobs: (manifest, expectedBlobs) =>
|
||||
mutate('commit', () =>
|
||||
store().commitStagedOrcadMigrationCatalog(manifest, { expectedBlobs })
|
||||
),
|
||||
blobs: {
|
||||
stageChunk: (request) =>
|
||||
mutate(`blob:${request.blob.blobKind}`, () => store().stageOrcadMigrationBlobChunk(request))
|
||||
},
|
||||
readHolding: async () => {
|
||||
at('read-holding')
|
||||
return {
|
||||
repoIds: store()
|
||||
.getRepos()
|
||||
.map((repo) => repo.id),
|
||||
folderWorkspaceIds: store()
|
||||
.getFolderWorkspaces()
|
||||
.map((folder) => folder.id),
|
||||
highestGeneration: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* D1b-10 fault injection over the real move driver and a real orcad profile store (#29826's
|
||||
* pattern): the desktop is killed at every point a clean move passes (each journal write, each
|
||||
* host call before it lands and after its reply is lost, each pause step, each desktop rewrite
|
||||
* store), optionally with orcad restarted too. After the restart and the reconnect, the move is
|
||||
* back at zero or fully moved, never in between; a second restart changes nothing; and a move
|
||||
* reset to zero can be run again from scratch.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WSL_HOSTS_INTERNAL_GATE_ENV } from '../../../shared/wsl-host-internal-gate'
|
||||
import { wslMoveJournalKey } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { createStore, testState } from '../../persistence-test-harness'
|
||||
import type { Store } from '../loading-store/store'
|
||||
import {
|
||||
createWslMoveWorld,
|
||||
type WslMoveWorld
|
||||
} from '../__fixtures__/wsl-mover/wsl-move-driver-world'
|
||||
import {
|
||||
createWslMoveKillSwitch,
|
||||
createWslMoveStoreHost,
|
||||
passWslMoveKillPoint,
|
||||
type WslMoveKillSwitch
|
||||
} from '../__fixtures__/wsl-mover/wsl-move-store-host'
|
||||
import type * as Journal from './wsl-move-cutover-journal'
|
||||
import { listWslMoveCutoverChain, listWslMoveCutovers } from './wsl-move-cutover-journal'
|
||||
import { runWslMoveDesktopRewritePass } from './wsl-move-desktop-rewrite'
|
||||
import {
|
||||
registerWslMoveDesktopRewriteStore,
|
||||
resetWslMoveDesktopRewriteStoresForTests,
|
||||
setWslMoveDesktopRewriteRegistryCompleteForTests
|
||||
} from './wsl-move-desktop-rewrite-stores'
|
||||
import { moveProjectsToWslHost } from './wsl-move-driver'
|
||||
import { resolveWslMoveFence } from './wsl-move-fence'
|
||||
import { listWslMoveOwedHostAborts, settleWslMoveOwedHostAborts } from './wsl-move-owed-host-aborts'
|
||||
import { recoverInterruptedWslMoves, resetWslMoveToZero } from './wsl-move-reset'
|
||||
import { runWslMoveRetentionPass } from './wsl-move-retention'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: () => testState.dir },
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
|
||||
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
|
||||
}
|
||||
}))
|
||||
vi.mock('../../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../../telemetry/cohort-classifier', () => ({
|
||||
getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 1 }))
|
||||
}))
|
||||
|
||||
// The journal's writes and removals are kill points too; the hook is set per test.
|
||||
const journalKill = vi.hoisted(() => ({ at: null as ((name: string) => void) | null }))
|
||||
vi.mock('./wsl-move-cutover-journal', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof Journal>()
|
||||
const around = <A extends unknown[]>(name: (...args: A) => string, run: (...args: A) => void) => {
|
||||
return (...args: A): void => {
|
||||
journalKill.at?.(`${name(...args)}:before`)
|
||||
run(...args)
|
||||
journalKill.at?.(`${name(...args)}:after`)
|
||||
}
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
writeWslMoveCutover: around(
|
||||
(_path: string, cutover: Parameters<typeof actual.writeWslMoveCutover>[1]) =>
|
||||
`journal:${cutover.phase}`,
|
||||
actual.writeWslMoveCutover
|
||||
),
|
||||
removeWslMoveCutover: around(() => 'journal:remove', actual.removeWslMoveCutover),
|
||||
removeUncommittedWslMoveCutover: around(
|
||||
() => 'journal:remove-uncommitted',
|
||||
actual.removeUncommittedWslMoveCutover
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const STORE_IDS = ['main-profile', 'renderer-ui', 'browser'] as const
|
||||
const request = { distro: 'Ubuntu' }
|
||||
|
||||
/** `zero-abort-owed`: the desktop is at zero; the host's leftover stage goes on its next contact. */
|
||||
type Outcome = 'back-at-zero' | 'zero-abort-owed' | 'fully-moved' | 'waiting-for-host' | 'stuck'
|
||||
|
||||
type Rig = {
|
||||
userDataPath: string
|
||||
kill: WslMoveKillSwitch
|
||||
world: WslMoveWorld
|
||||
host: Store
|
||||
rewritten: Map<string, number>
|
||||
}
|
||||
|
||||
let rig: Rig
|
||||
const dirs: string[] = []
|
||||
|
||||
function tempDir(prefix: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), prefix))
|
||||
dirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function newRig(): Rig {
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
setWslMoveDesktopRewriteRegistryCompleteForTests(true)
|
||||
testState.dir = tempDir('wsl-move-host-')
|
||||
const userDataPath = tempDir('wsl-move-desktop-')
|
||||
const kill = createWslMoveKillSwitch()
|
||||
const world = createWslMoveWorld(userDataPath)
|
||||
const next: Rig = { userDataPath, kill, world, host: createStore(), rewritten: new Map() }
|
||||
const at = (name: string): void => passWslMoveKillPoint(kill, name)
|
||||
journalKill.at = at
|
||||
const host = createWslMoveStoreHost({ store: () => next.host, kill })
|
||||
world.deps.connectHost = async () => {
|
||||
at('connect')
|
||||
return host
|
||||
}
|
||||
const { quiesce } = world.deps
|
||||
world.deps.quiesce = {
|
||||
sleepWorkspaces: async (ids) => (at('sleep'), quiesce.sleepWorkspaces(ids)),
|
||||
livePtyIds: quiesce.livePtyIds,
|
||||
captureRestoreHints: async (panes, distro) => (
|
||||
at('restore-hints'),
|
||||
quiesce.captureRestoreHints(panes, distro)
|
||||
),
|
||||
proveExited: async (scope) => (at('prove-exited'), quiesce.proveExited(scope))
|
||||
}
|
||||
for (const id of STORE_IDS) {
|
||||
registerWslMoveDesktopRewriteStore({
|
||||
id,
|
||||
side: 'main',
|
||||
run: () => {
|
||||
at(`store:${id}:run`)
|
||||
next.rewritten.set(id, 1)
|
||||
},
|
||||
flush: () => at(`store:${id}:flush`)
|
||||
})
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
const retentionStore = (r: Rig) => ({
|
||||
getRepos: () => r.world.repos,
|
||||
getFolderWorkspaces: () => [],
|
||||
setWslMoveRetainedMarks: () => 1,
|
||||
flushPendingOrThrowAsync: async () => {}
|
||||
})
|
||||
|
||||
/** Startup, then the user's next move attempt reaching the host (which settles the old one). */
|
||||
async function restart(r: Rig, options: { hostUp: boolean; orcadRestarted: boolean }) {
|
||||
r.kill.dead = false
|
||||
if (options.orcadRestarted) {
|
||||
await r.host.flushPendingOrThrowAsync().catch(() => {})
|
||||
r.host = createStore()
|
||||
}
|
||||
await recoverInterruptedWslMoves(r.userDataPath, () => {})
|
||||
await runWslMoveDesktopRewritePass({ userDataPath: r.userDataPath, warn: () => {} })
|
||||
if (options.hostUp) {
|
||||
// What the next move's prepare does once it reaches the host, before it stages anything.
|
||||
const key = wslMoveJournalKey('Ubuntu')
|
||||
const host = await r.world.deps.connectHost('Ubuntu')
|
||||
if (host) {
|
||||
await settleWslMoveOwedHostAborts(r.userDataPath, key, host.catalog)
|
||||
}
|
||||
const head = listWslMoveCutoverChain(r.userDataPath, key).at(-1)
|
||||
if (head) {
|
||||
await resetWslMoveToZero(
|
||||
{ userDataPath: r.userDataPath, destination: host?.catalog ?? null },
|
||||
head.migrationId
|
||||
)
|
||||
await r.world.deps.finishOnDesktop()
|
||||
}
|
||||
}
|
||||
await runWslMoveRetentionPass({ userDataPath: r.userDataPath, store: retentionStore(r) })
|
||||
}
|
||||
|
||||
function outcomeOf(r: Rig): Outcome {
|
||||
const records = listWslMoveCutovers(r.userDataPath)
|
||||
const fence = resolveWslMoveFence(r.userDataPath)
|
||||
const onHost = r.host.getRepos().some((repo) => repo.id === 'repo-1')
|
||||
if (records.length === 0) {
|
||||
const staged = r.host.hasStagedOrcadMigrationCatalog()
|
||||
const owed = listWslMoveOwedHostAborts(r.userDataPath).length > 0
|
||||
const clean =
|
||||
!onHost &&
|
||||
(!staged || owed) &&
|
||||
fence.fenced.repoIds.size === 0 &&
|
||||
fence.hidden.repoIds.size === 0 &&
|
||||
fence.sessionFrozen.repoIds.size === 0 &&
|
||||
r.rewritten.size === 0
|
||||
if (!clean) {
|
||||
return 'stuck'
|
||||
}
|
||||
return owed ? 'zero-abort-owed' : 'back-at-zero'
|
||||
}
|
||||
const [record] = records
|
||||
if (records.length === 1 && record.phase === 'retained') {
|
||||
const allOnce = STORE_IDS.every((id) => r.rewritten.get(id) === 1)
|
||||
return allOnce &&
|
||||
onHost &&
|
||||
fence.hidden.repoIds.has('repo-1') &&
|
||||
fence.fenced.repoIds.size === 0
|
||||
? 'fully-moved'
|
||||
: 'stuck'
|
||||
}
|
||||
if (records.length === 1 && record.phase === 'blobs-transferred') {
|
||||
return 'waiting-for-host'
|
||||
}
|
||||
return 'stuck'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv(WSL_HOSTS_INTERNAL_GATE_ENV, '1')
|
||||
// A killed process's catch handlers warn about the calls that now throw.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
rig = newRig()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
journalKill.at = null
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
resetWslMoveDesktopRewriteStoresForTests()
|
||||
for (const dir of dirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function killedMove(r: Rig, killAt: string): Promise<void> {
|
||||
r.kill.killAt = killAt
|
||||
await moveProjectsToWslHost(r.world.deps, request).catch(() => {})
|
||||
// Retention runs on the journal change after the move, in the same process.
|
||||
await runWslMoveRetentionPass({ userDataPath: r.userDataPath, store: retentionStore(r) }).catch(
|
||||
() => {}
|
||||
)
|
||||
expect(r.kill.dead, `kill point ${killAt} fired`).toBe(true)
|
||||
}
|
||||
|
||||
/** Every kill point a clean move passes, from a throwaway run. */
|
||||
async function killPoints(): Promise<string[]> {
|
||||
const probe = newRig()
|
||||
const result = await moveProjectsToWslHost(probe.world.deps, request)
|
||||
expect(result).toMatchObject({ outcome: 'moved' })
|
||||
await runWslMoveRetentionPass({ userDataPath: probe.userDataPath, store: retentionStore(probe) })
|
||||
return [...new Set(probe.kill.points)]
|
||||
}
|
||||
|
||||
describe('WSL move driver fault injection (real host store)', () => {
|
||||
it('a clean move ends fully moved on a real host store', async () => {
|
||||
expect(await moveProjectsToWslHost(rig.world.deps, request)).toMatchObject({
|
||||
outcome: 'moved'
|
||||
})
|
||||
await runWslMoveRetentionPass({ userDataPath: rig.userDataPath, store: retentionStore(rig) })
|
||||
expect(outcomeOf(rig)).toBe('fully-moved')
|
||||
})
|
||||
|
||||
it.each([false, true])(
|
||||
'every kill point ends at zero or fully moved, and survives a second restart (orcad restarted: %s)',
|
||||
{ timeout: 120_000 },
|
||||
async (orcadRestarted) => {
|
||||
const points = await killPoints()
|
||||
expect(points.length).toBeGreaterThan(30)
|
||||
const matrix: Record<string, { hostDown: Outcome; hostUp: Outcome }> = {}
|
||||
for (const killAt of points) {
|
||||
const r = newRig()
|
||||
await killedMove(r, killAt)
|
||||
await restart(r, { hostUp: false, orcadRestarted })
|
||||
const hostDown = outcomeOf(r)
|
||||
await restart(r, { hostUp: true, orcadRestarted })
|
||||
const hostUp = outcomeOf(r)
|
||||
matrix[killAt] = { hostDown, hostUp }
|
||||
await restart(r, { hostUp: true, orcadRestarted })
|
||||
expect(outcomeOf(r), `${killAt}: a second restart changes nothing`).toBe(hostUp)
|
||||
if (hostUp === 'back-at-zero') {
|
||||
const again = await moveProjectsToWslHost(r.world.deps, request)
|
||||
expect(again, `a fresh move after ${killAt}`).toMatchObject({ outcome: 'moved' })
|
||||
}
|
||||
}
|
||||
for (const [killAt, result] of Object.entries(matrix)) {
|
||||
expect(['back-at-zero', 'fully-moved'], `${killAt} with the host up`).toContain(
|
||||
result.hostUp
|
||||
)
|
||||
// Without the host, only a possible commit waits; everything else resolves locally.
|
||||
expect(
|
||||
['back-at-zero', 'zero-abort-owed', 'fully-moved', 'waiting-for-host'],
|
||||
killAt
|
||||
).toContain(result.hostDown)
|
||||
}
|
||||
// The commit point: a kill before the commit lands resets; one after it moves forward.
|
||||
expect(matrix['commit:before']).toEqual({
|
||||
hostDown: 'waiting-for-host',
|
||||
hostUp: 'back-at-zero'
|
||||
})
|
||||
expect(matrix['commit:lost-reply']).toEqual({
|
||||
hostDown: 'waiting-for-host',
|
||||
hostUp: 'fully-moved'
|
||||
})
|
||||
// A kill after the host staged: the desktop resets alone, and the stage goes on reconnect.
|
||||
expect(matrix['journal:destination-staged:after']).toEqual({
|
||||
hostDown: 'zero-abort-owed',
|
||||
hostUp: 'back-at-zero'
|
||||
})
|
||||
expect(matrix['store:browser:run']).toEqual({
|
||||
hostDown: 'fully-moved',
|
||||
hostUp: 'fully-moved'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import { assertWslMoveManifestFits } from './wsl-move-manifest-budget'
|
||||
import { WslMoveScopeChangedError } from './wsl-move-manifest-export'
|
||||
import { wslMoveHostReadiness } from './wsl-move-preflight'
|
||||
import { settleWslMoveOwedHostAborts } from './wsl-move-owed-host-aborts'
|
||||
import { resetWslMoveToZero } from './wsl-move-reset'
|
||||
import { assessWslMoveTerminals, type WslMoveGateBlocker } from './wsl-move-terminal-gate'
|
||||
|
||||
@@ -75,7 +76,10 @@ async function settleInterruptedWslMove(
|
||||
host: WslMoveHost,
|
||||
request: WslMoveRequest
|
||||
): Promise<WslMoveRefusal | null> {
|
||||
const head = listWslMoveCutoverChain(deps.userDataPath, wslMoveJournalKey(request.distro)).at(-1)
|
||||
const journalKey = wslMoveJournalKey(request.distro)
|
||||
// Before this move stages: a leftover stage of an earlier reset would refuse it.
|
||||
await settleWslMoveOwedHostAborts(deps.userDataPath, journalKey, host.catalog)
|
||||
const head = listWslMoveCutoverChain(deps.userDataPath, journalKey).at(-1)
|
||||
if (
|
||||
!head ||
|
||||
WSL_MOVE_CUTOVER_PHASES.indexOf(head.phase) >=
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* A stage a reset move may have left on the WSL host. A reset without the host's answer (a restart
|
||||
* while the host is down) lifts the desktop's record but cannot abort the host's stage, which then
|
||||
* keeps its claims on those projects for the stage's 7-day lifetime and refuses every retry. The
|
||||
* reset leaves this record; the next move into the distro aborts that stage by identity first.
|
||||
*/
|
||||
import type { OrcadMigrationCatalogReferenceAbortResult } from '../../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { isRecord } from '../../../shared/orcad-migration/orcad-migration-manifest-fields'
|
||||
import type { WslMoveCutover } from '../../../shared/wsl-move/wsl-move-cutover'
|
||||
import { createMigrationJournalDirectory } from '../migrating-orcad-catalog/migration-journal-directory'
|
||||
import type { OrcadMigrationDestinationCatalog } from '../../ssh/orcad-migration-cutover-coordinator'
|
||||
|
||||
export type WslMoveOwedHostAbort = {
|
||||
migrationId: string
|
||||
manifestSha256: string
|
||||
journalKey: string
|
||||
}
|
||||
|
||||
const owed = createMigrationJournalDirectory<WslMoveOwedHostAbort>({
|
||||
// Its own directory: builds without it never read it, and their stage simply expires.
|
||||
directoryName: 'orcad-migration-wsl-owed-aborts',
|
||||
maxFileBytes: 4096,
|
||||
parse: parseOwedAbort,
|
||||
unreadable: (detail) => new Error(`wsl_move_owed_aborts_unreadable: ${detail}`)
|
||||
})
|
||||
|
||||
function parseOwedAbort(value: unknown): WslMoveOwedHostAbort {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
typeof value.migrationId !== 'string' ||
|
||||
typeof value.manifestSha256 !== 'string' ||
|
||||
!/^[a-f0-9]{64}$/.test(value.manifestSha256) ||
|
||||
typeof value.journalKey !== 'string'
|
||||
) {
|
||||
throw new Error('wsl_move_owed_abort_invalid')
|
||||
}
|
||||
return {
|
||||
migrationId: value.migrationId,
|
||||
manifestSha256: value.manifestSha256,
|
||||
journalKey: value.journalKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Durable before it returns, so it lands before the record it outlives is removed. */
|
||||
export function recordWslMoveOwedHostAbort(
|
||||
userDataPath: string,
|
||||
cutover: Pick<WslMoveCutover, 'migrationId' | 'manifestSha256' | 'journalKey'>
|
||||
): void {
|
||||
owed.write(userDataPath, {
|
||||
migrationId: cutover.migrationId,
|
||||
manifestSha256: cutover.manifestSha256,
|
||||
journalKey: cutover.journalKey
|
||||
})
|
||||
}
|
||||
|
||||
export function listWslMoveOwedHostAborts(userDataPath: string): WslMoveOwedHostAbort[] {
|
||||
return owed.list(userDataPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts each owed stage of this distro on its now-connected host. Only an answer settles one:
|
||||
* a failure keeps it for the next move, whose own stage the leftover would refuse anyway.
|
||||
*/
|
||||
export async function settleWslMoveOwedHostAborts(
|
||||
userDataPath: string,
|
||||
journalKey: string,
|
||||
destination: Pick<OrcadMigrationDestinationCatalog, 'abortByReference'>,
|
||||
warn: (message: string) => void = (message) => console.warn(message)
|
||||
): Promise<void> {
|
||||
let pending: WslMoveOwedHostAbort[]
|
||||
try {
|
||||
pending = owed.list(userDataPath).filter((entry) => entry.journalKey === journalKey)
|
||||
} catch (error) {
|
||||
warn(`[wsl-move] Could not read the owed host aborts: ${describe(error)}`)
|
||||
return
|
||||
}
|
||||
for (const entry of pending) {
|
||||
let answer: OrcadMigrationCatalogReferenceAbortResult
|
||||
try {
|
||||
answer = await destination.abortByReference(entry)
|
||||
} catch (error) {
|
||||
warn(`[wsl-move] Could not abort a leftover stage on the host: ${describe(error)}`)
|
||||
continue
|
||||
}
|
||||
if (answer.state === 'committed') {
|
||||
// No commit was ever requested for it; leave the host's state alone and stop asking.
|
||||
warn(`[wsl-move] A reset move ${entry.migrationId} reads as committed on the host`)
|
||||
}
|
||||
owed.remove(userDataPath, entry.migrationId)
|
||||
}
|
||||
}
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
removeUncommittedWslMoveCutover,
|
||||
writeWslMoveCutover
|
||||
} from './wsl-move-cutover-journal'
|
||||
import { recordWslMoveOwedHostAbort } from './wsl-move-owed-host-aborts'
|
||||
|
||||
/** The only phase from which the desktop may ask the destination to commit. */
|
||||
export const WSL_MOVE_COMMIT_REQUEST_PHASE: WslMoveCutoverPhase = 'blobs-transferred'
|
||||
@@ -53,6 +54,8 @@ export type WslMoveResetContext = {
|
||||
|
||||
const phaseIndex = (phase: WslMoveCutoverPhase): number => WSL_MOVE_CUTOVER_PHASES.indexOf(phase)
|
||||
const COMMITTED_FROM = phaseIndex('destination-committed')
|
||||
// The stage request goes out from `quiesced`, so from there the host may hold one.
|
||||
const STAGE_MAY_EXIST_FROM = phaseIndex('quiesced')
|
||||
|
||||
export async function resetWslMoveToZero(
|
||||
context: WslMoveResetContext,
|
||||
@@ -68,7 +71,13 @@ export async function resetWslMoveToZero(
|
||||
return { outcome: 'committed', cutover }
|
||||
}
|
||||
const commitMayExist = cutover.phase === WSL_MOVE_COMMIT_REQUEST_PHASE
|
||||
const release = (evidence: 'commit-never-requested' | 'catalog-absent'): WslMoveResetResult => {
|
||||
const release = (
|
||||
evidence: 'commit-never-requested' | 'catalog-absent',
|
||||
hostConfirmedAbsent = false
|
||||
): WslMoveResetResult => {
|
||||
if (!hostConfirmedAbsent && phaseIndex(cutover.phase) >= STAGE_MAY_EXIST_FROM) {
|
||||
recordWslMoveOwedHostAbort(context.userDataPath, cutover)
|
||||
}
|
||||
removeUncommittedWslMoveCutover(context.userDataPath, migrationId)
|
||||
return { outcome: 'reset', evidence }
|
||||
}
|
||||
@@ -94,7 +103,7 @@ export async function resetWslMoveToZero(
|
||||
return commitMayExist ? byReference() : release('commit-never-requested')
|
||||
}
|
||||
if (state.state === 'absent') {
|
||||
return release(commitMayExist ? 'catalog-absent' : 'commit-never-requested')
|
||||
return release(commitMayExist ? 'catalog-absent' : 'commit-never-requested', true)
|
||||
}
|
||||
if (state.state !== 'committed') {
|
||||
return { outcome: 'unverifiable', reason: `catalog_${state.state}` }
|
||||
@@ -114,7 +123,10 @@ async function resetByReference(
|
||||
context: WslMoveResetContext & { destination: OrcadMigrationDestinationCatalog },
|
||||
cutover: WslMoveCutover,
|
||||
commitMayExist: boolean,
|
||||
release: (evidence: 'commit-never-requested' | 'catalog-absent') => WslMoveResetResult
|
||||
release: (
|
||||
evidence: 'commit-never-requested' | 'catalog-absent',
|
||||
hostConfirmedAbsent?: boolean
|
||||
) => WslMoveResetResult
|
||||
): Promise<WslMoveResetResult> {
|
||||
let aborted: OrcadMigrationCatalogReferenceAbortResult
|
||||
try {
|
||||
@@ -125,7 +137,7 @@ async function resetByReference(
|
||||
: release('commit-never-requested')
|
||||
}
|
||||
if (aborted.state === 'absent') {
|
||||
return release(commitMayExist ? 'catalog-absent' : 'commit-never-requested')
|
||||
return release(commitMayExist ? 'catalog-absent' : 'commit-never-requested', true)
|
||||
}
|
||||
if (!commitMayExist) {
|
||||
return { outcome: 'unverifiable', reason: 'committed_without_commit_request' }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { parseOrcadMigrationCatalogReferenceAbortResult } from '../../shared/orcad-migration/orcad-migration-catalog-state'
|
||||
import type {
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationManifest
|
||||
OrcadMigrationCatalogReference,
|
||||
OrcadMigrationCatalogReferenceAbortResult
|
||||
} from '../../shared/orcad-migration/orcad-migration-manifest'
|
||||
import { ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
@@ -19,7 +19,7 @@ export const ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED =
|
||||
/** Sends only the manifest's identity, for a manifest whose whole request would overflow the frame. */
|
||||
export async function abortRemoteOrcadMigrationCatalogByReference(
|
||||
pairingCode: string,
|
||||
manifest: OrcadMigrationManifest,
|
||||
manifest: OrcadMigrationCatalogReference,
|
||||
options: CatalogRequestOptions = {}
|
||||
): Promise<OrcadMigrationCatalogReferenceAbortResult> {
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
import type {
|
||||
OrcadMigrationCatalogAbortResult,
|
||||
OrcadMigrationCatalogReference,
|
||||
OrcadMigrationCatalogReferenceAbortResult,
|
||||
OrcadMigrationCatalogState,
|
||||
OrcadMigrationManifest
|
||||
@@ -45,7 +46,7 @@ export type OrcadMigrationDestinationCatalog = {
|
||||
abort: (manifest: OrcadMigrationManifest) => Promise<OrcadMigrationCatalogAbortResult>
|
||||
/** Sends only the manifest's identity; for a journal whose manifest no request can carry. */
|
||||
abortByReference: (
|
||||
manifest: OrcadMigrationManifest
|
||||
manifest: OrcadMigrationCatalogReference
|
||||
) => Promise<OrcadMigrationCatalogReferenceAbortResult>
|
||||
stageChunk: (
|
||||
request: OrcadMigrationSnapshotChunkRequest
|
||||
|
||||
@@ -17,7 +17,8 @@ export type FakeOrcadMigrationDestination = { commits: number } & {
|
||||
*/
|
||||
export function fakeOrcadMigrationDestination(): FakeOrcadMigrationDestination {
|
||||
const states = new Map<string, 'absent' | 'staged' | 'committed'>()
|
||||
const stateOf = (manifest: OrcadMigrationManifest) => states.get(manifest.migrationId) ?? 'absent'
|
||||
const stateOf = (manifest: Pick<OrcadMigrationManifest, 'migrationId'>) =>
|
||||
states.get(manifest.migrationId) ?? 'absent'
|
||||
const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => {
|
||||
const state = stateOf(manifest)
|
||||
const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 }
|
||||
|
||||
Reference in New Issue
Block a user