From 6f96efb662ab0086a4ecc1eba50d128eb9481086 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:25:47 -0700 Subject: [PATCH] fix(orcad-migration): a host fenced on a manifest no request can carry can be cancelled by its id (#30864) * fix(orcad-migration): a source fenced on a manifest no request can carry can be aborted by its identity A desktop that fenced a manifest just under the export cap before #30836 could never send its abort: every catalog request carries the whole manifest and refuses it before sending. The abort now asks the server by migration id and digest instead (orcad.migration.abortCatalogByReference, behind a new capability), and releases only on the server's durable absent answer. A committed server still refuses the release. Co-authored-by: Claude * test(orcad-migration): cover the durable abort by reference and its RPC method Co-authored-by: Claude --------- Co-authored-by: Claude --- .../orcad-runtime-conversion-wiring.ts | 3 + ...ersistence-orcad-migration-catalog.test.ts | 33 ++++++ .../orcad-catalog-import.ts | 31 ++--- .../orcad-catalog-reference-abort.ts | 54 +++++++++ .../runtime/orca-runtime-migration-catalog.ts | 18 +++ .../orcad-migration-catalog-import.test.ts | 30 +++++ .../runtime/orcad-migration-catalog-import.ts | 18 +++ .../rpc/methods/orcad-migration.test.ts | 8 +- .../runtime/rpc/methods/orcad-migration.ts | 13 +++ src/main/runtime/runtime-store-contract.ts | 1 + ...ration-catalog-client-frame-budget.test.ts | 46 +++++++- .../ssh/orcad-migration-catalog-client.ts | 2 +- ...rcad-migration-catalog-reference-client.ts | 63 +++++++++++ ...rcad-migration-cutover-coordinator.test.ts | 106 +++++++++++++++++- .../orcad-migration-cutover-coordinator.ts | 15 +++ .../ssh/orcad-migration-destination-fake.ts | 12 ++ .../ssh/orcad-migration-manifest-export.ts | 10 +- .../orcad-migration-repository-link.test.ts | 1 + .../orcad-migration-catalog-state.ts | 30 +++++ .../orcad-migration-manifest.ts | 12 ++ src/shared/orcad-runtime-capabilities.ts | 6 + .../rpc-contract/orcad-migration-params.ts | 6 + .../rpc-params-catalog.generated.ts | 2 + 23 files changed, 500 insertions(+), 20 deletions(-) create mode 100644 src/main/persistence/migrating-orcad-catalog/orcad-catalog-reference-abort.ts create mode 100644 src/main/ssh/orcad-migration-catalog-reference-client.ts diff --git a/src/main/managed-server/lifecycle/orcad-runtime-conversion-wiring.ts b/src/main/managed-server/lifecycle/orcad-runtime-conversion-wiring.ts index 313df02034f..239c2c80dd2 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-conversion-wiring.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-conversion-wiring.ts @@ -14,6 +14,7 @@ import { stageRemoteOrcadMigrationCatalog, stageRemoteOrcadMigrationSnapshotChunk } from '../../ssh/orcad-migration-catalog-client' +import { abortRemoteOrcadMigrationCatalogByReference } from '../../ssh/orcad-migration-catalog-reference-client' import { censusHostRelayTerminalsFor } from '../../ssh/legacy-relay/ssh-host-relay-census-for-target' import type { OrcadManagedConversionArgs } from './orcad-runtime-conversion' import type { OrcadServerRepository } from '../../ssh/orcad-migration-repository-link' @@ -33,6 +34,8 @@ export function orcadMigrationDestinationFor( stage: (manifest) => stageRemoteOrcadMigrationCatalog(pairingCode, manifest, options), commit: (manifest) => commitRemoteOrcadMigrationCatalog(pairingCode, manifest, options), abort: (manifest) => abortRemoteOrcadMigrationCatalog(pairingCode, manifest, options), + abortByReference: (manifest) => + abortRemoteOrcadMigrationCatalogByReference(pairingCode, manifest, options), stageChunk: (request) => stageRemoteOrcadMigrationSnapshotChunk(pairingCode, request, options) } } diff --git a/src/main/persistence-orcad-migration-catalog.test.ts b/src/main/persistence-orcad-migration-catalog.test.ts index eac21dc8326..3f6f95cc26d 100644 --- a/src/main/persistence-orcad-migration-catalog.test.ts +++ b/src/main/persistence-orcad-migration-catalog.test.ts @@ -384,6 +384,39 @@ describe('orcad migration catalog persistence', () => { expect(store.getRepos()).toHaveLength(1) }) + it('aborts by reference from its own staged copy, never a different manifest or a commit', () => { + const store = createStore() + const input = manifest() + const reference = { migrationId: input.migrationId, manifestSha256: input.manifestSha256 } + + expect(store.abortStagedOrcadMigrationCatalogByReference(reference)).toEqual({ + ...reference, + state: 'absent', + aborted: false + }) + store.stageOrcadMigrationCatalog(input) + expect(() => + store.abortStagedOrcadMigrationCatalogByReference({ + ...reference, + manifestSha256: 'f'.repeat(64) + }) + ).toThrow('orcad_migration_id_reused_with_different_manifest') + expect(store.getOrcadMigrationCatalogState(input).state).toBe('staged') + expect(store.abortStagedOrcadMigrationCatalogByReference(reference)).toEqual({ + ...reference, + state: 'absent', + aborted: true + }) + expect(store.getOrcadMigrationCatalogState(input).state).toBe('absent') + commitCatalog(store, input) + expect(store.abortStagedOrcadMigrationCatalogByReference(reference)).toEqual({ + ...reference, + state: 'committed', + aborted: false + }) + expect(store.getRepos()).toHaveLength(1) + }) + it('requires a durable stage before commit and bounds concurrent dormant catalogs', () => { const store = createStore() const input = manifest() diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts index 270e1c0b7b0..55b82bc5135 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-import.ts @@ -2,6 +2,8 @@ import { MAX_ORCAD_MIGRATION_STAGED_CATALOGS } from '../../../shared/orcad-migra import { ORCAD_MIGRATION_MANIFEST_VERSION } from '../../../shared/orcad-migration/orcad-migration-manifest-validation' import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReference, + OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationImportReceipt, OrcadMigrationManifest @@ -52,6 +54,11 @@ import { stageOrcadMigrationBlobChunk } from './orcad-migration-blob-staging' import { assertOrcadMigrationBlobContentsValid } from './orcad-migration-blob-installers' +import { + abortOrcadMigrationCatalogByReference, + committedMigrations, + stagedManifests +} from './orcad-catalog-reference-abort' type OrcadCatalogImportRuntime = OrcadMigrationCommitGenerationRuntime & Pick @@ -194,6 +201,17 @@ export class OrcadCatalogImportPersistence { return { ...absentCatalogState(manifest), aborted: true } } + /** For a source whose manifest no request can carry: this server's own copy stands in for it. */ + abortStagedOrcadMigrationCatalogByReference( + reference: OrcadMigrationCatalogReference + ): OrcadMigrationCatalogReferenceAbortResult { + return abortOrcadMigrationCatalogByReference( + this[orcadCatalogImportContext].runtime.state, + reference, + (manifest) => this.abortStagedOrcadMigrationCatalog(manifest) + ) + } + getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState { const context = this[orcadCatalogImportContext] return migrationCatalogState(context.runtime, manifest) @@ -251,19 +269,6 @@ function migrationCatalogState( return stagedCatalogState(manifest, staged.stagedAt, runtime) } -function stagedManifests(state: StoreRuntimeState['state']): OrcadMigrationManifest[] { - return (state.orcadMigrationStagedCatalogs ?? []).map((entry) => entry.manifest) -} - -function committedMigrations( - state: StoreRuntimeState['state'] -): { migrationId: string; manifestSha256: string }[] { - return [ - ...(state.orcadMigrationImportReceipts ?? []), - ...(state.orcadMigrationEvictedReceipts ?? []) - ] -} - /** * The receipt alone proves the commit: the server is live afterwards, so its rows and snapshot * files may legitimately change, and a later read must still say "committed". diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-catalog-reference-abort.ts b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-reference-abort.ts new file mode 100644 index 00000000000..10c064661e9 --- /dev/null +++ b/src/main/persistence/migrating-orcad-catalog/orcad-catalog-reference-abort.ts @@ -0,0 +1,54 @@ +import type { + OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReference, + OrcadMigrationCatalogReferenceAbortResult, + OrcadMigrationManifest +} from '../../../shared/orcad-migration/orcad-migration-manifest' +import type { StoreRuntimeState } from '../loading-store/store-runtime-state' + +export function stagedManifests(state: StoreRuntimeState['state']): OrcadMigrationManifest[] { + return (state.orcadMigrationStagedCatalogs ?? []).map((entry) => entry.manifest) +} + +export function committedMigrations( + state: StoreRuntimeState['state'] +): { migrationId: string; manifestSha256: string }[] { + return [ + ...(state.orcadMigrationImportReceipts ?? []), + ...(state.orcadMigrationEvictedReceipts ?? []) + ] +} + +/** + * Aborts by identity, with the stored manifest standing in for the one the source cannot send. + * A commit wins over a stage, as it does for an abort that carries the manifest. + */ +export function abortOrcadMigrationCatalogByReference( + state: StoreRuntimeState['state'], + reference: OrcadMigrationCatalogReference, + abortStaged: (manifest: OrcadMigrationManifest) => OrcadMigrationCatalogAbortResult +): OrcadMigrationCatalogReferenceAbortResult { + const identity = { migrationId: reference.migrationId, manifestSha256: reference.manifestSha256 } + const committed = committedMigrations(state).find( + (entry) => entry.migrationId === reference.migrationId + ) + const staged = stagedManifests(state).find( + (manifest) => manifest.migrationId === reference.migrationId + ) + const held = committed ?? staged + if (held && held.manifestSha256 !== reference.manifestSha256) { + throw new Error('orcad_migration_id_reused_with_different_manifest') + } + if (committed) { + return { ...identity, state: 'committed', aborted: false } + } + if (!staged) { + return { ...identity, state: 'absent', aborted: false } + } + const result = abortStaged(staged) + return { + ...identity, + state: result.state === 'committed' ? 'committed' : 'absent', + aborted: result.aborted + } +} diff --git a/src/main/runtime/orca-runtime-migration-catalog.ts b/src/main/runtime/orca-runtime-migration-catalog.ts index 4ad0cda66a9..97717b49e17 100644 --- a/src/main/runtime/orca-runtime-migration-catalog.ts +++ b/src/main/runtime/orca-runtime-migration-catalog.ts @@ -1,5 +1,7 @@ import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReference, + OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest } from '../../shared/orcad-migration/orcad-migration-manifest' @@ -13,6 +15,7 @@ import type { OrcadMigrationBlobDescriptor } from '../../shared/orcad-migration/orcad-migration-blob-transfer' import { + abortStagedOrcadMigrationCatalogByReferenceDurably, abortStagedOrcadMigrationCatalogDurably, commitStagedOrcadMigrationCatalogDurably, getOrcadMigrationCatalogState, @@ -27,6 +30,7 @@ type MigrationStoreKey = | 'stageOrcadMigrationSnapshotChunk' | 'stageOrcadMigrationBlobChunk' | 'abortStagedOrcadMigrationCatalog' + | 'abortStagedOrcadMigrationCatalogByReference' | 'getOrcadMigrationCatalogState' | 'flushPendingOrThrowAsync' @@ -94,6 +98,20 @@ export abstract class OrcaRuntimeWithMigrationCatalog extends OrcaRuntimeWithRes }) } + async abortStagedOrcadMigrationCatalogByReference( + reference: OrcadMigrationCatalogReference, + options: { signal?: AbortSignal } = {} + ): Promise { + return abortStagedOrcadMigrationCatalogByReferenceDurably({ + store: this.requireMigrationStore( + 'abortStagedOrcadMigrationCatalogByReference', + 'flushPendingOrThrowAsync' + ), + reference, + signal: options.signal + }) + } + getOrcadMigrationCatalogState(manifest: OrcadMigrationManifest): OrcadMigrationCatalogState { return getOrcadMigrationCatalogState({ store: this.requireMigrationStore('getOrcadMigrationCatalogState'), diff --git a/src/main/runtime/orcad-migration-catalog-import.test.ts b/src/main/runtime/orcad-migration-catalog-import.test.ts index 98571aa99b0..4559220a2bc 100644 --- a/src/main/runtime/orcad-migration-catalog-import.test.ts +++ b/src/main/runtime/orcad-migration-catalog-import.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest } from '../../shared/orcad-migration/orcad-migration-manifest' import { + abortStagedOrcadMigrationCatalogByReferenceDurably, abortStagedOrcadMigrationCatalogDurably, commitStagedOrcadMigrationCatalogDurably, stageOrcadMigrationCatalogDurably @@ -78,4 +80,32 @@ describe('durable orcad migration catalog import', () => { }) expect(flush).toHaveBeenCalledTimes(2) }) + + it('answers an abort by reference absent only once its flush succeeds', async () => { + const reference = { migrationId: 'migration-1', manifestSha256: 'a'.repeat(64) } + const aborted: OrcadMigrationCatalogReferenceAbortResult = { + ...reference, + state: 'absent', + aborted: true + } + const unchanged: OrcadMigrationCatalogReferenceAbortResult = { ...aborted, aborted: false } + const abort = vi.fn().mockReturnValueOnce(aborted).mockReturnValueOnce(unchanged) + const flush = vi.fn().mockRejectedValueOnce(new Error('disk full')).mockResolvedValue(undefined) + const args = { + store: { + abortStagedOrcadMigrationCatalogByReference: abort, + flushPendingOrThrowAsync: flush + }, + reference + } + + await expect(abortStagedOrcadMigrationCatalogByReferenceDurably(args)).rejects.toThrow( + 'disk full' + ) + await expect(abortStagedOrcadMigrationCatalogByReferenceDurably(args)).resolves.toEqual({ + ...unchanged, + durableAbsent: true + }) + expect(flush).toHaveBeenCalledTimes(2) + }) }) diff --git a/src/main/runtime/orcad-migration-catalog-import.ts b/src/main/runtime/orcad-migration-catalog-import.ts index 426314f5c40..88db0a7141a 100644 --- a/src/main/runtime/orcad-migration-catalog-import.ts +++ b/src/main/runtime/orcad-migration-catalog-import.ts @@ -1,5 +1,7 @@ import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReference, + OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest } from '../../shared/orcad-migration/orcad-migration-manifest' @@ -16,6 +18,8 @@ type OrcadMigrationCatalogCommitStore = OrcadMigrationCatalogFlushStore & Partial> type OrcadMigrationCatalogAbortStore = OrcadMigrationCatalogFlushStore & Partial> +type OrcadMigrationCatalogAbortByReferenceStore = OrcadMigrationCatalogFlushStore & + Partial> export async function stageOrcadMigrationCatalogDurably(args: { store: OrcadMigrationCatalogStageStore @@ -76,6 +80,20 @@ export async function abortStagedOrcadMigrationCatalogDurably(args: { return result } +export async function abortStagedOrcadMigrationCatalogByReferenceDurably(args: { + store: OrcadMigrationCatalogAbortByReferenceStore + reference: OrcadMigrationCatalogReference + signal?: AbortSignal +}): Promise { + // SAFETY: Returned or mutable Store slots retain their original native missing-method failure. + const result = args.store.abortStagedOrcadMigrationCatalogByReference!(args.reference) + if (result.state === 'absent') { + await flushMigrationState(args.store, args.signal) + return { ...result, durableAbsent: true } + } + return result +} + export function getOrcadMigrationCatalogState(args: { store: Partial< Pick diff --git a/src/main/runtime/rpc/methods/orcad-migration.test.ts b/src/main/runtime/rpc/methods/orcad-migration.test.ts index af768e6932e..0ff4b7dc148 100644 --- a/src/main/runtime/rpc/methods/orcad-migration.test.ts +++ b/src/main/runtime/rpc/methods/orcad-migration.test.ts @@ -31,6 +31,7 @@ const MIGRATION_METHOD_NAMES = [ 'orcad.migration.stageSnapshotChunk', 'orcad.migration.stageBlobChunk', 'orcad.migration.abortCatalog', + 'orcad.migration.abortCatalogByReference', 'orcad.migration.catalogState' ] as const @@ -126,7 +127,9 @@ describe('orcad migration RPC', () => { ? 'stageOrcadMigrationBlobChunk' : name === 'orcad.migration.abortCatalog' ? 'abortStagedOrcadMigrationCatalog' - : 'getOrcadMigrationCatalogState' + : name === 'orcad.migration.abortCatalogByReference' + ? 'abortStagedOrcadMigrationCatalogByReference' + : 'getOrcadMigrationCatalogState' await expect( method.handler( @@ -264,6 +267,9 @@ function migrationMethodInput(name: (typeof MIGRATION_METHOD_NAMES)[number]) { if (name === 'orcad.migration.stageBlobChunk') { return migrationBlobRequest(input) } + if (name === 'orcad.migration.abortCatalogByReference') { + return { migrationId: input.migrationId, manifestSha256: input.manifestSha256 } + } return name === 'orcad.migration.stageSnapshotChunk' ? migrationSnapshotRequest(input) : { manifest: input } diff --git a/src/main/runtime/rpc/methods/orcad-migration.ts b/src/main/runtime/rpc/methods/orcad-migration.ts index 1d3f7b30848..94b222c5a51 100644 --- a/src/main/runtime/rpc/methods/orcad-migration.ts +++ b/src/main/runtime/rpc/methods/orcad-migration.ts @@ -1,6 +1,7 @@ import type { z } from 'zod' import { OrcadMigrationCatalogParams, + OrcadMigrationCatalogReferenceParams, OrcadMigrationCommitCatalogParams } from '../../../../shared/rpc-contract/orcad-migration-params' import { parseOrcadMigrationManifest } from '../../../../shared/orcad-migration/orcad-migration-manifest-validation' @@ -65,6 +66,18 @@ export const ORCAD_MIGRATION_METHODS = [ }) } }), + // Why a reference: a source fenced on a manifest no request can carry must still be releasable. + defineMethod({ + name: 'orcad.migration.abortCatalogByReference', + permission: 'host-admin', + params: OrcadMigrationCatalogReferenceParams, + handler: async (params, context) => { + requireMigrationRuntimeClient(context) + return context.runtime.abortStagedOrcadMigrationCatalogByReference(params, { + signal: context.signal + }) + } + }), defineMethod({ name: 'orcad.migration.catalogState', permission: 'host-admin', diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 2ed414e5502..c6562c35804 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -61,6 +61,7 @@ export type RuntimeStore = { stageOrcadMigrationSnapshotChunk?: Store['stageOrcadMigrationSnapshotChunk'] stageOrcadMigrationBlobChunk?: Store['stageOrcadMigrationBlobChunk'] abortStagedOrcadMigrationCatalog?: Store['abortStagedOrcadMigrationCatalog'] + abortStagedOrcadMigrationCatalogByReference?: Store['abortStagedOrcadMigrationCatalogByReference'] getOrcadMigrationCatalogState?: Store['getOrcadMigrationCatalogState'] installOrcadMigrationReceivedBlobs?: Store['installOrcadMigrationReceivedBlobs'] installPendingOrcadMigrationReceivedBlobs?: Store['installPendingOrcadMigrationReceivedBlobs'] diff --git a/src/main/ssh/orcad-migration-catalog-client-frame-budget.test.ts b/src/main/ssh/orcad-migration-catalog-client-frame-budget.test.ts index cd523ce9112..c2912d427bc 100644 --- a/src/main/ssh/orcad-migration-catalog-client-frame-budget.test.ts +++ b/src/main/ssh/orcad-migration-catalog-client-frame-budget.test.ts @@ -7,11 +7,13 @@ import { } from '../../shared/orcad-migration/orcad-migration-manifest-validation' import { ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY, ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY } from '../../shared/orcad-runtime-capabilities' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' const sendRequest = vi.hoisted(() => vi.fn()) +const extraCapabilities = vi.hoisted((): string[] => []) vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequestWithStatusPreflight: async ( pairing: unknown, @@ -27,7 +29,8 @@ vi.mock('../../shared/remote-runtime-client', () => ({ capabilities: [ ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, ORCAD_MIGRATION_BLOB_CHANNEL_RUNTIME_CAPABILITY, - 'orcad.migration-blob.agent-session-records.v1' + 'orcad.migration-blob.agent-session-records.v1', + ...extraCapabilities ] } }) @@ -37,6 +40,10 @@ vi.mock('../../shared/remote-runtime-client', () => ({ const { commitRemoteOrcadMigrationCatalog, stageRemoteOrcadMigrationCatalog } = await import('./orcad-migration-catalog-client') +const { + abortRemoteOrcadMigrationCatalogByReference, + ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED +} = await import('./orcad-migration-catalog-reference-client') const pairingCode = encodePairingOffer({ v: PAIRING_OFFER_VERSION, @@ -68,6 +75,7 @@ const manyBlobs: OrcadMigrationBlobDescriptor[] = Array.from({ length: 200 }, (_ beforeEach(() => { vi.clearAllMocks() + extraCapabilities.length = 0 sendRequest.mockResolvedValue({ ok: true, _meta: { runtimeId: 'runtime' }, @@ -103,4 +111,40 @@ describe("orcad migration requests fit the host's sealed inbound frame", () => { ).rejects.toThrow(/too large to move to its Orca server/) expect(sendRequest).not.toHaveBeenCalled() }) + + it('aborts a manifest no request can carry by its identity alone, durably', async () => { + extraCapabilities.push(ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY) + const manifest = manifestOfBytes(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) + const identity = { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { ...identity, state: 'absent', aborted: true, durableAbsent: true } + }) + await expect( + abortRemoteOrcadMigrationCatalogByReference(pairingCode, manifest) + ).resolves.toEqual({ ...identity, state: 'absent', aborted: true, durableAbsent: true }) + expect(sendRequest).toHaveBeenCalledWith( + expect.anything(), + 'orcad.migration.abortCatalogByReference', + identity + ) + + sendRequest.mockResolvedValueOnce({ + ok: true, + result: { ...identity, state: 'absent', aborted: true } + }) + await expect( + abortRemoteOrcadMigrationCatalogByReference(pairingCode, manifest) + ).rejects.toThrow('orcad_migration_catalog_abort_durability_invalid') + }) + + it('refuses, unsent, an abort by reference to a host that predates it', async () => { + await expect( + abortRemoteOrcadMigrationCatalogByReference( + pairingCode, + manifestOfBytes(MAX_ORCAD_MIGRATION_MANIFEST_BYTES) + ) + ).rejects.toThrow(ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED) + expect(sendRequest).not.toHaveBeenCalled() + }) }) diff --git a/src/main/ssh/orcad-migration-catalog-client.ts b/src/main/ssh/orcad-migration-catalog-client.ts index f6e92d27f9f..a9c74efb96d 100644 --- a/src/main/ssh/orcad-migration-catalog-client.ts +++ b/src/main/ssh/orcad-migration-catalog-client.ts @@ -255,7 +255,7 @@ async function request( return response.result } -async function sendSupportedMigrationRequest( +export async function sendSupportedMigrationRequest( pairing: NonNullable>, method: string, params: unknown, diff --git a/src/main/ssh/orcad-migration-catalog-reference-client.ts b/src/main/ssh/orcad-migration-catalog-reference-client.ts new file mode 100644 index 00000000000..50960604eb4 --- /dev/null +++ b/src/main/ssh/orcad-migration-catalog-reference-client.ts @@ -0,0 +1,63 @@ +import { parseOrcadMigrationCatalogReferenceAbortResult } from '../../shared/orcad-migration/orcad-migration-catalog-state' +import type { + OrcadMigrationCatalogReferenceAbortResult, + OrcadMigrationManifest +} 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' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + ORCAD_MIGRATION_DESTINATION_UNSUPPORTED, + sendSupportedMigrationRequest, + type CatalogRequestOptions +} from './orcad-migration-catalog-client' + +/** Not the release evidence `ORCAD_MIGRATION_DESTINATION_UNSUPPORTED` is: the host may hold a stage. */ +export const ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED = + 'orcad_migration_destination_abort_by_reference_unsupported:Update Orca on this host to cancel this move.' + +/** Sends only the manifest's identity, for a manifest whose whole request would overflow the frame. */ +export async function abortRemoteOrcadMigrationCatalogByReference( + pairingCode: string, + manifest: OrcadMigrationManifest, + options: CatalogRequestOptions = {} +): Promise { + const pairing = parsePairingCode(pairingCode) + if (!pairing) { + throw new Error('orcad_migration_pairing_code_invalid') + } + let supportsReference = false + let response: RuntimeRpcResponse + try { + response = await sendSupportedMigrationRequest( + pairing, + 'orcad.migration.abortCatalogByReference', + { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 }, + options, + false, + null, + (capabilities) => { + supportsReference = capabilities.includes( + ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY + ) + if (!supportsReference) { + throw new Error(ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED) + } + } + ) + } catch (error) { + // A missing method on a host that advertised it proves nothing about what it staged. + if ( + supportsReference && + error instanceof Error && + error.message === ORCAD_MIGRATION_DESTINATION_UNSUPPORTED + ) { + throw new Error(ORCAD_MIGRATION_DESTINATION_ABORT_BY_REFERENCE_UNSUPPORTED) + } + throw error + } + if (!response.ok) { + throw new Error(`orcad_migration_abort_failed:${response.error.message}`) + } + return parseOrcadMigrationCatalogReferenceAbortResult(response.result, manifest) +} diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts index 38ebb2400be..cf8005a81c1 100644 --- a/src/main/ssh/orcad-migration-cutover-coordinator.test.ts +++ b/src/main/ssh/orcad-migration-cutover-coordinator.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' import type { SshTarget } from '../../shared/ssh-types' +import type { OrcadMigrationManifest } from '../../shared/orcad-migration/orcad-migration-manifest' import { closeTestStores, createSqliteTestStore } from '../persistence-test-harness' import { Store } from '../persistence/loading-store/store' import { @@ -13,7 +14,15 @@ import { type OrcadMigrationCutoverContext } from './orcad-migration-cutover-coordinator' import { ORCAD_MIGRATION_DESTINATION_UNSUPPORTED } from './orcad-migration-catalog-client' -import { listOrcadMigrationSourceCutovers } from './orcad-migration-cutover-journal' +import { + listOrcadMigrationSourceCutovers, + writeOrcadMigrationSourceCutover +} from './orcad-migration-cutover-journal' +import { + assertOrcadMigrationRequestFits, + createOrcadMigrationManifest +} from './orcad-migration-manifest-export' +import { MAX_ORCAD_MIGRATION_MANIFEST_BYTES } from '../../shared/orcad-migration/orcad-migration-manifest-validation' import { fenceOrcadMigrationSource } from './orcad-migration-source-fence' import { SshTargetOrcadClaims } from './targets/ssh-target-orcad-claims' import { fakeOrcadMigrationDestination as fakeDestination } from './orcad-migration-destination-fake' @@ -257,4 +266,99 @@ describe('migration cutover coordinator', () => { ) expect(h.journal()?.phase).toBe('source-fenced') }) + + describe('a journal fenced before #30836 on a manifest no request can carry', () => { + async function stuck(stagedByOlderBuild = false) { + const h = await setup() + const cutover = h.journal()! + const target = { + ...h.store.getSshTarget(TARGET.id)!, + generation: cutover.sshTargetGeneration + } + const exportFor = () => + createOrcadMigrationManifest(h.store, target, { + migrationId: cutover.migrationId, + destinationEnvironmentId: cutover.destinationEnvironmentId + }) + const baseline = JSON.stringify(exportFor()).length + // Under the export cap, over the sealed-frame request budget. + h.store.updateRepo(h.store.getRepo('repo-1')!, { + displayName: 'x'.repeat(MAX_ORCAD_MIGRATION_MANIFEST_BYTES - 200 - baseline) + }) + const manifest = exportFor() + expect(() => assertOrcadMigrationRequestFits({ manifest })).toThrow( + /orcad_migration_manifest_too_large/ + ) + writeOrcadMigrationSourceCutover(h.context.userDataPath, { + ...cutover, + manifestSha256: manifest.manifestSha256, + manifest + }) + if (stagedByOlderBuild) { + // A build before the client-side size check sent it whole, and the frame fit. + await h.destination.stage(manifest) + } + // As the real client does: every request that carries the manifest refuses unsent. + const refuseUnsent = + (real: (sent: OrcadMigrationManifest) => Promise) => + async (sent: OrcadMigrationManifest): Promise => { + assertOrcadMigrationRequestFits({ manifest: sent }) + return real(sent) + } + const d = h.destination + d.readState.mockImplementation(refuseUnsent(d.readState.getMockImplementation()!)) + d.stage.mockImplementation(refuseUnsent(d.stage.getMockImplementation()!)) + d.commit.mockImplementation(refuseUnsent(d.commit.getMockImplementation()!)) + d.abort.mockImplementation(refuseUnsent(d.abort.getMockImplementation()!)) + return { ...h, manifest } + } + + it('releases the fence once the server, asked by reference, proves it holds nothing', async () => { + const h = await stuck() + await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).resolves.toEqual({ + outcome: 'released', + evidence: 'catalog-absent' + }) + expect(h.destination.abortByReference).toHaveBeenCalledWith(h.manifest) + expect(h.owner()).toBeNull() + expect(h.journal()).toBeUndefined() + }) + + it('aborts, by reference, a stage an older build delivered whole', async () => { + const h = await stuck(true) + await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).resolves.toMatchObject({ + outcome: 'released' + }) + await expect(h.destination.abortByReference.mock.results[0]?.value).resolves.toMatchObject({ + state: 'absent', + aborted: true + }) + expect(h.owner()).toBeNull() + }) + + it('keeps the fence when the server cannot answer by reference', async () => { + const h = await stuck() + h.destination.abortByReference.mockRejectedValueOnce(new Error('update required')) + await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).rejects.toThrow( + 'update required' + ) + expect(h.owner()).toBe('env-1') + expect(h.journal()?.migrationId).toBe(h.migrationId) + }) + + it('never releases a server that already committed it', async () => { + const h = await stuck() + h.destination.abortByReference.mockResolvedValueOnce({ + migrationId: h.migrationId, + manifestSha256: h.manifest.manifestSha256, + state: 'committed', + aborted: false + }) + await expect(abortOrcadMigrationCutover(h.context, h.migrationId)).resolves.toMatchObject({ + outcome: 'refused', + code: 'orcad_migration_committed_source_cannot_be_released' + }) + expect(h.owner()).toBe('env-1') + }) + }) }) diff --git a/src/main/ssh/orcad-migration-cutover-coordinator.ts b/src/main/ssh/orcad-migration-cutover-coordinator.ts index a8cbbba2429..ccbb357aac5 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, + OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest } from '../../shared/orcad-migration/orcad-migration-manifest' @@ -22,6 +23,7 @@ import type { } from '../../shared/orcad-migration/orcad-migration-source-cutover' import { resolveDurableOrcadCatalogMutation } from './orcad-catalog-durable-mutation' import { ORCAD_MIGRATION_DESTINATION_UNSUPPORTED } from './orcad-migration-catalog-client' +import { orcadMigrationRequestFits } from './orcad-migration-manifest-export' import { listOrcadMigrationSourceCutovers, writeOrcadMigrationSourceCutover @@ -41,6 +43,10 @@ export type OrcadMigrationDestinationCatalog = { stage: (manifest: OrcadMigrationManifest) => Promise commit: (manifest: OrcadMigrationManifest) => Promise abort: (manifest: OrcadMigrationManifest) => Promise + /** Sends only the manifest's identity; for a journal whose manifest no request can carry. */ + abortByReference: ( + manifest: OrcadMigrationManifest + ) => Promise stageChunk: ( request: OrcadMigrationSnapshotChunkRequest ) => Promise @@ -134,6 +140,15 @@ export async function abortOrcadMigrationCutover( return committedRefusal() } const { destination } = context + // A journal from before #30836 may hold a manifest no request can carry; only abort could not. + if (!orcadMigrationRequestFits({ manifest: cutover.manifest })) { + const aborted = await destination.abortByReference(cutover.manifest) + if (aborted.state === 'committed') { + return committedRefusal() + } + await release(cutover) + return { outcome: 'released', evidence: 'catalog-absent' } + } let state: OrcadMigrationCatalogState try { state = await destination.readState(cutover.manifest) diff --git a/src/main/ssh/orcad-migration-destination-fake.ts b/src/main/ssh/orcad-migration-destination-fake.ts index 82b5b613915..85d48777efd 100644 --- a/src/main/ssh/orcad-migration-destination-fake.ts +++ b/src/main/ssh/orcad-migration-destination-fake.ts @@ -66,6 +66,18 @@ export function fakeOrcadMigrationDestination(): FakeOrcadMigrationDestination { } return { ...view(manifest), aborted, ...(aborted ? {} : { durableAbsent: true as const }) } }), + abortByReference: vi.fn(async (manifest) => { + const identity = { + migrationId: manifest.migrationId, + manifestSha256: manifest.manifestSha256 + } + if (stateOf(manifest) === 'committed') { + return { ...identity, state: 'committed', aborted: false } + } + const aborted = stateOf(manifest) === 'staged' + states.delete(manifest.migrationId) + return { ...identity, state: 'absent', aborted, durableAbsent: true } + }), stageChunk: vi.fn() } return destination diff --git a/src/main/ssh/orcad-migration-manifest-export.ts b/src/main/ssh/orcad-migration-manifest-export.ts index 05279a49173..249650096a0 100644 --- a/src/main/ssh/orcad-migration-manifest-export.ts +++ b/src/main/ssh/orcad-migration-manifest-export.ts @@ -119,9 +119,7 @@ export const ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES = export function assertOrcadMigrationRequestFits(params: { manifest: OrcadMigrationManifest }): void { - if ( - Buffer.byteLength(JSON.stringify(params), 'utf8') > ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES - ) { + if (!orcadMigrationRequestFits(params)) { throw new Error( describeOversizedOrcadMigrationPayload( params.manifest.payload, @@ -131,6 +129,12 @@ export function assertOrcadMigrationRequestFits(params: { } } +export function orcadMigrationRequestFits(params: { manifest: OrcadMigrationManifest }): boolean { + return ( + Buffer.byteLength(JSON.stringify(params), 'utf8') <= ORCAD_MIGRATION_REQUEST_PARAMS_BUDGET_BYTES + ) +} + /** The bare code told users nothing (#30126): name the largest parts and what to trim. */ export function describeOversizedOrcadMigrationPayload( payload: OrcadMigrationCatalogPayload, diff --git a/src/main/ssh/orcad-migration-repository-link.test.ts b/src/main/ssh/orcad-migration-repository-link.test.ts index b52e6c9210b..d8fa96c18c3 100644 --- a/src/main/ssh/orcad-migration-repository-link.test.ts +++ b/src/main/ssh/orcad-migration-repository-link.test.ts @@ -100,6 +100,7 @@ function serverDestination(): typeof destination { const result = server.abortStagedOrcadMigrationCatalog(manifest) return result.aborted ? result : { ...result, durableAbsent: true as const } }, + abortByReference: vi.fn(), stageChunk: async (request) => server.stageOrcadMigrationSnapshotChunk(request) } } diff --git a/src/shared/orcad-migration/orcad-migration-catalog-state.ts b/src/shared/orcad-migration/orcad-migration-catalog-state.ts index 6419a600e3f..3580f0fe613 100644 --- a/src/shared/orcad-migration/orcad-migration-catalog-state.ts +++ b/src/shared/orcad-migration/orcad-migration-catalog-state.ts @@ -1,6 +1,7 @@ import { normalizeOrcadMigrationImportReceipts } from './orcad-migration-manifest-validation' import type { OrcadMigrationCatalogAbortResult, + OrcadMigrationCatalogReferenceAbortResult, OrcadMigrationCatalogState, OrcadMigrationManifest } from './orcad-migration-manifest' @@ -77,6 +78,35 @@ export function parseOrcadMigrationCatalogAbortResult( } } +/** Absent only with the host's flush behind it: an unflushed abort could come back on restart. */ +export function parseOrcadMigrationCatalogReferenceAbortResult( + value: unknown, + manifest: OrcadMigrationManifest +): OrcadMigrationCatalogReferenceAbortResult { + const record = requireRecord(value) + if ( + record.migrationId !== manifest.migrationId || + record.manifestSha256 !== manifest.manifestSha256 + ) { + throw new Error('orcad_migration_catalog_state_identity_mismatch') + } + if (typeof record.aborted !== 'boolean') { + throw new Error('orcad_migration_catalog_abort_result_invalid') + } + if (record.state === 'committed') { + return { state: 'committed', ...migrationIdentity(manifest), aborted: false } + } + if (record.state === 'absent' && record.durableAbsent === true) { + return { + state: 'absent', + ...migrationIdentity(manifest), + aborted: record.aborted, + durableAbsent: true + } + } + throw new Error('orcad_migration_catalog_abort_durability_invalid') +} + function migrationIdentity(manifest: OrcadMigrationManifest) { return { migrationId: manifest.migrationId, manifestSha256: manifest.manifestSha256 } } diff --git a/src/shared/orcad-migration/orcad-migration-manifest.ts b/src/shared/orcad-migration/orcad-migration-manifest.ts index 0de03f92e1a..8a49d648bed 100644 --- a/src/shared/orcad-migration/orcad-migration-manifest.ts +++ b/src/shared/orcad-migration/orcad-migration-manifest.ts @@ -149,6 +149,18 @@ export type OrcadMigrationCatalogAbortResult = OrcadMigrationCatalogState & { durableAbsent?: true } +export type OrcadMigrationCatalogReference = Pick< + OrcadMigrationManifest, + 'migrationId' | 'manifestSha256' +> + +/** An abort by reference: no receipt, since an evicted one is rebuilt only from the manifest. */ +export type OrcadMigrationCatalogReferenceAbortResult = OrcadMigrationCatalogReference & { + state: 'absent' | 'committed' + aborted: boolean + durableAbsent?: true +} + export function orcadMigrationManifestHashInput( manifest: Omit ): string { diff --git a/src/shared/orcad-runtime-capabilities.ts b/src/shared/orcad-runtime-capabilities.ts index 888c1bfb60d..d732e437a49 100644 --- a/src/shared/orcad-runtime-capabilities.ts +++ b/src/shared/orcad-runtime-capabilities.ts @@ -6,6 +6,11 @@ export const ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY = 'orcad.terminal-census.v // reads a catalog only on a host advertising this, and treats its absence as a refusal. export const ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY = 'orcad.migration-catalog.v1' as const +// Why: older hosts have no orcad.migration.abortCatalogByReference. A source fenced on a manifest +// no request can carry (before #30836 refused one) can be released only through it. +export const ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY = + 'orcad.migration-catalog-abort-by-reference.v1' as const + // Why per kind: an older destination drops `source.kind` and cannot tell a WSL source from an SSH // one, so a client sends a non-SSH manifest only to a host advertising that kind. Not advertised // until a destination parses `source.kind: 'local-wsl'`. @@ -23,5 +28,6 @@ export const HOST_ACCOUNT_LAUNCH_ENV_RUNTIME_CAPABILITY = 'accounts.host-launch- export const ORCAD_RUNTIME_CAPABILITIES = [ ORCAD_TERMINAL_CENSUS_RUNTIME_CAPABILITY, ORCAD_MIGRATION_CATALOG_RUNTIME_CAPABILITY, + ORCAD_MIGRATION_CATALOG_ABORT_BY_REFERENCE_RUNTIME_CAPABILITY, HOST_ACCOUNT_LAUNCH_ENV_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/rpc-contract/orcad-migration-params.ts b/src/shared/rpc-contract/orcad-migration-params.ts index f6cbfa05ba6..9ff4d5dc5ed 100644 --- a/src/shared/rpc-contract/orcad-migration-params.ts +++ b/src/shared/rpc-contract/orcad-migration-params.ts @@ -15,3 +15,9 @@ export const OrcadMigrationCommitCatalogParams = OrcadMigrationCatalogParams.ext .max(MAX_ORCAD_MIGRATION_BLOBS) .optional() }) + +// The manifest's identity alone, for a request the whole manifest would overflow. +export const OrcadMigrationCatalogReferenceParams = z.object({ + migrationId: z.string().min(1).max(256), + manifestSha256: z.string().regex(/^[a-f0-9]{64}$/) +}) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index fd232b39adf..f553f2a7ee1 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -405,6 +405,7 @@ import { } from './notifications-params' import { OrcadMigrationCatalogParams, + OrcadMigrationCatalogReferenceParams, OrcadMigrationCommitCatalogParams } from './orcad-migration-params' import { @@ -1140,6 +1141,7 @@ export const RPC_PARAMS_BY_METHOD = { 'notifications.unregisterPush': null, 'notifications.unsubscribe': NotificationUnsubscribeParams, 'orcad.migration.abortCatalog': OrcadMigrationCatalogParams, + 'orcad.migration.abortCatalogByReference': OrcadMigrationCatalogReferenceParams, 'orcad.migration.catalogState': OrcadMigrationCatalogParams, 'orcad.migration.commitCatalog': OrcadMigrationCommitCatalogParams, 'orcad.migration.stageBlobChunk': OrcadMigrationBlobChunkRequestSchema,