From e8fa137a86e43272d86cd252dbe7826ba1a0b18a Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 22 Oct 2026 04:15:58 -0700 Subject: [PATCH] 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 --- .../wsl-mover/wsl-move-store-host.ts | 122 +++++++ .../wsl-move-driver-fault-injection.test.ts | 309 ++++++++++++++++++ .../wsl-move-driver-plan.ts | 6 +- .../wsl-move-owed-host-aborts.ts | 95 ++++++ .../moving-wsl-projects/wsl-move-reset.ts | 20 +- ...rcad-migration-catalog-reference-client.ts | 6 +- .../orcad-migration-cutover-coordinator.ts | 3 +- .../ssh/orcad-migration-destination-fake.ts | 3 +- 8 files changed, 554 insertions(+), 10 deletions(-) create mode 100644 src/main/persistence/__fixtures__/wsl-mover/wsl-move-store-host.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-driver-fault-injection.test.ts create mode 100644 src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.ts diff --git a/src/main/persistence/__fixtures__/wsl-mover/wsl-move-store-host.ts b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-store-host.ts new file mode 100644 index 00000000000..f30f23c79f4 --- /dev/null +++ b/src/main/persistence/__fixtures__/wsl-mover/wsl-move-store-host.ts @@ -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 `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 (name: string, apply: () => T): Promise => { + 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 + } + } + } +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-fault-injection.test.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-fault-injection.test.ts new file mode 100644 index 00000000000..f0f89fabe5b --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-fault-injection.test.ts @@ -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() + const around = (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[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 +} + +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 { + 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 { + 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 = {} + 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' + }) + } + ) +}) diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts b/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts index 4d63de7148e..d4f5a17c0fd 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-driver-plan.ts @@ -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 { - 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) >= diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.ts b/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.ts new file mode 100644 index 00000000000..7b10d892a90 --- /dev/null +++ b/src/main/persistence/moving-wsl-projects/wsl-move-owed-host-aborts.ts @@ -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({ + // 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 +): 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, + warn: (message: string) => void = (message) => console.warn(message) +): Promise { + 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) +} diff --git a/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts b/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts index 1db53da81a3..3f4403bf20c 100644 --- a/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts +++ b/src/main/persistence/moving-wsl-projects/wsl-move-reset.ts @@ -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 { 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' } diff --git a/src/main/ssh/orcad-migration-catalog-reference-client.ts b/src/main/ssh/orcad-migration-catalog-reference-client.ts index 50960604eb4..8fabcadb0c1 100644 --- a/src/main/ssh/orcad-migration-catalog-reference-client.ts +++ b/src/main/ssh/orcad-migration-catalog-reference-client.ts @@ -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 { const pairing = parsePairingCode(pairingCode) diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.ts b/src/main/ssh/orcad-migration-cutover-coordinator.ts index 46544801584..02123241880 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.ts @@ -9,6 +9,7 @@ */ import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReference, OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest @@ -45,7 +46,7 @@ export type OrcadMigrationDestinationCatalog = { abort: (manifest: OrcadMigrationManifest) => Promise /** Sends only the manifest's identity; for a journal whose manifest no request can carry. */ abortByReference: ( - manifest: OrcadMigrationManifest + manifest: OrcadMigrationCatalogReference ) => Promise stageChunk: ( request: OrcadMigrationSnapshotChunkRequest diff --git a/src/main/ssh/orcad-migration-destination-fake.ts b/src/main/ssh/orcad-migration-destination-fake.ts index 85d48777efd..2e66d210482 100644 --- a/src/main/ssh/orcad-migration-destination-fake.ts +++ b/src/main/ssh/orcad-migration-destination-fake.ts @@ -17,7 +17,8 @@ export type FakeOrcadMigrationDestination = { commits: number } & { */ export function fakeOrcadMigrationDestination(): FakeOrcadMigrationDestination { const states = new Map() - const stateOf = (manifest: OrcadMigrationManifest) => states.get(manifest.migrationId) ?? 'absent' + const stateOf = (manifest: Pick) => + states.get(manifest.migrationId) ?? 'absent' const view = (manifest: OrcadMigrationManifest): OrcadMigrationCatalogState => { const state = stateOf(manifest) const base = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 }