diff --git a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts index 3daa268749..3d8102b844 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-guest-session-mounts.test.ts @@ -39,7 +39,7 @@ test('retains a successful Guest mount and rehydrates the same authority after r }, }); - const result = await first.importInvitation(invitation('guest-one'), false); + const result = await first.importInvitation(invitation('guest-one'), false, 'import-one'); assert.equal(result.kind, 'connected'); if (result.kind !== 'connected') return; await first.close(); @@ -68,7 +68,7 @@ test('removes failed activation desire instead of creating recoverable profile s }, }); - const result = await mounts.importInvitation(invitation('guest-two'), false); + const result = await mounts.importInvitation(invitation('guest-two'), false, 'import-two'); assert.deepEqual(result.kind === 'error' ? result.reason : result.kind, 'peer_path_unavailable'); assert.deepEqual(await store.read(), []); assert.equal(unmounted.length, 1); @@ -94,7 +94,7 @@ test('settles admitted finalization before committing unmount desire', async () throw new Error('connection shutdown failed'); }, }); - const importing = mounts.importInvitation(invitation('guest-three'), false); + const importing = mounts.importInvitation(invitation('guest-three'), false, 'import-three'); await finalizing; const [retained] = await store.read(); assert.ok(retained); @@ -204,8 +204,9 @@ test('settles admitted finalization before closing and retains the mount', async }, }); - const importing = mounts.importInvitation(invitation('guest-closing'), false); + const importing = mounts.importInvitation(invitation('guest-closing'), false, 'import-closing'); await finalizing; + assert.equal(mounts.cancelImport('import-closing'), 'settling'); let closed = false; const closing = mounts.close().then(() => { closed = true; @@ -235,7 +236,7 @@ test('retains and reconciles a mount when finalization outcome is unknown', asyn }, }); - const result = await mounts.importInvitation(invitation('guest-unknown'), false); + const result = await mounts.importInvitation(invitation('guest-unknown'), false, 'import-unknown'); assert.equal(result.kind, 'error'); assert.equal((await store.read()).length, 1); await reconciled; @@ -244,6 +245,34 @@ test('retains and reconciles a mount when finalization outcome is unknown', asyn await mounts.close(); }); +test('cancels an in-flight import and removes its durable mount desire', async () => { + const store = memoryStore(); + let connecting!: () => void; + const started = new Promise((resolve) => { + connecting = resolve; + }); + const mounts = service(store, { + mount: async (_target, signal) => { + connecting(); + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }); + }, + }); + + const importing = mounts.importInvitation( + invitation('guest-cancelled'), + false, + 'import-cancelled', + ); + await started; + assert.equal(mounts.cancelImport('import-cancelled'), 'cancelled'); + + assert.equal((await importing).kind, 'error'); + assert.deepEqual(await store.read(), []); + await mounts.close(); +}); + function service( store: GuestSessionMountStore, overrides: { diff --git a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts index 053647b292..09de646e14 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-management.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-management.test.ts @@ -19,12 +19,14 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { RuntimeHostOperationError } from '@maka/runtime-host/client'; import { RUNTIME_HOST_OPERATOR_PEER_RELAY_DISCOVERY_CAPABILITY, runtimeHostAccessCredentialFingerprint, type RuntimeHostServiceManagementFrame, } from '@maka/runtime-host/operator'; import { createDesktopRuntimeHostManagement } from '../runtime-host-management.js'; +import { createDesktopRuntimeHostPeerMeshManagement } from '../runtime-host-peer-mesh-management.js'; import type { DesktopRuntimeHostManagementProvider } from '../runtime-host-management-provider.js'; import type { DesktopRuntimeHostSshAccessInput, @@ -37,6 +39,92 @@ import type { const DEPLOYMENT_ID = '11111111-1111-4111-8111-111111111111'; +test('cancels a live Runtime Host Mesh status query', async () => { + const handlers = new Map unknown>(); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const pending = new Promise(() => undefined); + const management = createDesktopRuntimeHostPeerMeshManagement({ + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + localHost: { + getSnapshot: async () => assert.fail('status must use the live Host'), + inspectManaged: async () => assert.fail('status must use the live Host'), + }, + runLocal: async () => assert.fail('status must use the live Host'), + liveHost: () => ({ + request: ((operation: string) => { + assert.equal(operation, 'peer.mesh.query'); + markStarted(); + return pending; + }) as never, + }), + profiles: { + resolveManagedService: async () => assert.fail('status must use the live Host'), + }, + runRemote: async () => assert.fail('status must use the live Host'), + }); + const execute = handlers.get('runtime-host-peer-mesh:execute'); + const cancel = handlers.get('runtime-host-peer-mesh:cancel'); + assert.ok(execute && cancel); + + const status = execute( + {}, + { kind: 'local_host' }, + 'status', + undefined, + undefined, + undefined, + undefined, + 'status-1', + ) as Promise; + await started; + cancel({}, 'status-1'); + await assert.rejects(status, /cancelled/u); + management.close(); +}); + +test('projects an unknown live Mesh mutation outcome across IPC', async () => { + const handlers = new Map unknown>(); + const management = createDesktopRuntimeHostPeerMeshManagement({ + ipcMain: { + handle: (channel, handler) => handlers.set(channel, handler as (...args: unknown[]) => unknown), + removeHandler: (channel) => handlers.delete(channel), + }, + localHost: { + getSnapshot: async () => assert.fail('mutation must use the live Host'), + inspectManaged: async () => assert.fail('mutation must use the live Host'), + }, + runLocal: async () => assert.fail('mutation must use the live Host'), + liveHost: () => ({ + request: (() => + Promise.reject( + new RuntimeHostOperationError( + 'peer.mesh.close', + 'commit_outcome_unknown', + 'Mesh close outcome is unknown', + ), + )) as never, + }), + profiles: { + resolveManagedService: async () => assert.fail('mutation must use the live Host'), + }, + runRemote: async () => assert.fail('mutation must use the live Host'), + }); + const execute = handlers.get('runtime-host-peer-mesh:execute'); + assert.ok(execute); + + assert.deepEqual( + await execute({}, { kind: 'local_host' }, 'close', 'mesh-1', undefined, undefined, undefined, 'close-1'), + { kind: 'outcome_unknown' }, + ); + management.close(); +}); + test('requires explicit interruption authority before a provider restarts active work', async () => { const handlers = new Map unknown>(); const provider = { diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index f9779c9c68..4f5f78ee3c 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -514,7 +514,7 @@ const runtimeHostProfileService = createDesktopRuntimeHostProfileService({ }); const guestSessionMountService = createDesktopGuestSessionMountService({ store: createGuestSessionMountStore(runtimeHostCredentialStore), - mount: async (target, signal) => { + mount: async (target, signal, onConnectionPhase) => { if (target.profile.kind !== 'remote' || !target.credential) { throw new Error('A shared Session requires a remote Guest target'); } @@ -522,6 +522,7 @@ const guestSessionMountService = createDesktopGuestSessionMountService({ await runtimeHostManager.mountGuest( { profile: target.profile, credential: target.credential }, signal, + onConnectionPhase, ); }, finalizeAccess: async (mountId, signal) => { diff --git a/apps/desktop/src/main/runtime-host-desktop-manager.ts b/apps/desktop/src/main/runtime-host-desktop-manager.ts index 6a0c4e3f85..7953e0e1d8 100644 --- a/apps/desktop/src/main/runtime-host-desktop-manager.ts +++ b/apps/desktop/src/main/runtime-host-desktop-manager.ts @@ -32,6 +32,7 @@ import { type ResolvedRuntimeHostProfile, type RuntimeHostReconnectBackoff, type RuntimeHostReconnectLifecycle, + type RuntimeHostConnectionPhase, type RuntimeHostRetirementMode, type RuntimeHostSshInteraction, } from '@maka/runtime-host/client'; @@ -70,6 +71,7 @@ export interface RuntimeHostDesktopManager { mountGuest( profileTarget: NonNullable, signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, ): Promise; finalizeGuestAccess(mountId: string, signal?: AbortSignal): Promise; unmountGuest(mountId: string): Promise; @@ -500,12 +502,13 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { mountGuest( profileTarget: NonNullable, signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, ): Promise { if (!isSessionGuestProfile(profileTarget.profile)) { return Promise.reject(new Error('A Session Guest target is required')); } return this.#mutateTarget(profileTarget.profile.id, () => - this.#enable(profileTarget, true, signal), + this.#enable(profileTarget, true, signal, onConnectionPhase), ); } @@ -513,6 +516,7 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { profileTarget: NonNullable, allowSameRoot: boolean, signal?: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, ): Promise { signal?.throwIfAborted(); if (this.#closed) throw new Error('Desktop Runtime Host manager is closed'); @@ -540,7 +544,10 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager { ) return; if (existing) await this.#removeTarget(existing); - const target = this.#createTarget(withRuntimeHostTarget(this.#baseInput, profileTarget)); + const target = this.#createTarget({ + ...withRuntimeHostTarget(this.#baseInput, profileTarget), + ...(onConnectionPhase ? { onConnectionPhase } : {}), + }); this.#targets.set(profileId, target); this.#publishState(target, { epoch: target.epoch, diff --git a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts index c31941d5c3..0b890f4872 100644 --- a/apps/desktop/src/main/runtime-host-guest-session-mounts.ts +++ b/apps/desktop/src/main/runtime-host-guest-session-mounts.ts @@ -22,11 +22,14 @@ import { decodeRemoteRuntimeHostProfile, RUNTIME_HOST_ACCESS_CREDENTIAL_MAX_BYTES, type ResolvedRuntimeHostProfile, + type RuntimeHostConnectionPhase, type RuntimeHostRemoteTransport, } from '@maka/runtime-host/client'; import { decodeCollaborationInvitationCode } from '@maka/runtime-host/protocol'; import type { CredentialStore } from '@maka/storage/credential-store'; import type { + SessionCollaborationCancelResult, + SessionCollaborationImportPhase, SessionCollaborationImportResult, SessionCollaborationMountSummary, } from '../shared/session-collaboration.js'; @@ -51,15 +54,27 @@ interface GuestSessionMountDocument { readonly mounts: readonly GuestSessionMount[]; } -interface LiveGuestActivation { - readonly kind: 'import' | 'startup'; +interface LiveGuestActivationBase { readonly controller: AbortController; - mountId?: string; stage: 'connecting' | 'finalizing'; finalization?: Promise; task: Promise; } +interface LiveGuestImportActivation extends LiveGuestActivationBase { + readonly kind: 'import'; + readonly operationId: string; + readonly onProgress?: (phase: SessionCollaborationImportPhase) => void; + mountId?: string; +} + +interface LiveGuestStartupActivation extends LiveGuestActivationBase { + readonly kind: 'startup'; + readonly mountId: string; +} + +type LiveGuestActivation = LiveGuestImportActivation | LiveGuestStartupActivation; + export interface GuestSessionMountStore { read(): Promise; write(mounts: readonly GuestSessionMount[]): Promise; @@ -71,7 +86,10 @@ export interface DesktopGuestSessionMountService { importInvitation( code: string, allowInsecure: boolean, + operationId: string, + onProgress?: (phase: SessionCollaborationImportPhase) => void, ): Promise; + cancelImport(operationId: string): SessionCollaborationCancelResult; remove(mountId: string): Promise; close(): Promise; } @@ -105,7 +123,11 @@ export function createGuestSessionMountStore( export function createDesktopGuestSessionMountService(input: { readonly store: GuestSessionMountStore; - readonly mount: (target: ResolvedRuntimeHostProfile, signal: AbortSignal) => Promise; + readonly mount: ( + target: ResolvedRuntimeHostProfile, + signal: AbortSignal, + onConnectionPhase?: (phase: RuntimeHostConnectionPhase) => void, + ) => Promise; readonly finalizeAccess: (mountId: string, signal: AbortSignal) => Promise; readonly unmount: (mountId: string) => Promise; readonly wait?: (delayMs: number, signal: AbortSignal) => Promise; @@ -145,12 +167,22 @@ export function createDesktopGuestSessionMountService(input: { mount: GuestSessionMount, ): Promise => { activation.stage = 'connecting'; - await input.mount(resolveMountTarget(mount), activation.controller.signal); + await input.mount(resolveMountTarget(mount), activation.controller.signal, (phase) => { + if (activation.kind === 'import') { + reportImportProgress( + activation.onProgress, + collaborationProgressForConnectionPhase(phase), + ); + } + }); activation.controller.signal.throwIfAborted(); if (removingMounts.has(mount.mountId)) { throw new Error('Shared Session mount was removed while connecting'); } activation.stage = 'finalizing'; + if (activation.kind === 'import') { + reportImportProgress(activation.onProgress, 'finalizing_access'); + } const finalization = input.finalizeAccess(mount.mountId, activation.controller.signal); activation.finalization = finalization; try { @@ -240,8 +272,9 @@ export function createDesktopGuestSessionMountService(input: { const runImport = async ( code: string, allowInsecure: boolean, - activation: LiveGuestActivation, + activation: LiveGuestImportActivation, ): Promise => { + reportImportProgress(activation.onProgress, 'validating_invitation'); activation.controller.signal.throwIfAborted(); let bundle; let invitation; @@ -278,6 +311,7 @@ export function createDesktopGuestSessionMountService(input: { } let reconcile = false; try { + reportImportProgress(activation.onProgress, 'discovering_host'); await activate(activation, mount); activation.controller.signal.throwIfAborted(); if (!(await load()).has(mount.mountId)) { @@ -313,10 +347,21 @@ export function createDesktopGuestSessionMountService(input: { const importInvitation = ( code: string, allowInsecure: boolean, + operationId: string, + onProgress?: (phase: SessionCollaborationImportPhase) => void, ): Promise => { if (closed) return Promise.reject(new Error('Shared Session mount service is closed')); - const activation: LiveGuestActivation = { + if ( + [...activations].some( + (activation) => activation.kind === 'import' && activation.operationId === operationId, + ) + ) { + return Promise.reject(new Error('Shared Session import operation is already active')); + } + const activation: LiveGuestImportActivation = { kind: 'import', + operationId, + ...(onProgress ? { onProgress } : {}), controller: new AbortController(), stage: 'connecting', task: Promise.resolve(), @@ -344,6 +389,17 @@ export function createDesktopGuestSessionMountService(input: { importInvitation, + cancelImport(operationId) { + const operation = [...activations].find( + (activation): activation is LiveGuestImportActivation => + activation.kind === 'import' && activation.operationId === operationId, + ); + if (!operation) return 'settling'; + if (operation.stage === 'finalizing') return 'settling'; + operation.controller.abort(new Error('Shared Session import was cancelled')); + return 'cancelled'; + }, + remove, async close() { @@ -366,14 +422,25 @@ export function registerDesktopGuestSessionMountIpc( ): () => void { const channels = [ 'session-collaboration:import', + 'session-collaboration:import:cancel', 'session-collaboration:mount:list', 'session-collaboration:mount:remove', ] as const; - ipcMain.handle(channels[0], (_event, code: string, allowInsecure: boolean) => - service.importInvitation(code, allowInsecure), + ipcMain.handle( + channels[0], + (event, code: string, allowInsecure: boolean, operationIdValue: unknown) => { + const operationId = requireOperationId(operationIdValue); + return service.importInvitation(code, allowInsecure, operationId, (phase) => { + if (!event.sender.isDestroyed()) { + event.sender.send('session-collaboration:import:progress', operationId, phase); + } + }); + }, ); - ipcMain.handle(channels[1], () => service.list()); - ipcMain.handle(channels[2], (_event, mountId: string) => service.remove(mountId)); + ipcMain.handle(channels[1], (_event, operationIdValue: unknown) => + service.cancelImport(requireOperationId(operationIdValue))); + ipcMain.handle(channels[2], () => service.list()); + ipcMain.handle(channels[3], (_event, mountId: string) => service.remove(mountId)); return () => { for (const channel of channels) ipcMain.removeHandler(channel); }; @@ -443,6 +510,40 @@ function isPeerPathUnavailable(error: unknown): boolean { return error.code === 'direct_path_unavailable' || error.code === 'transit_unavailable'; } +function collaborationProgressForConnectionPhase( + phase: RuntimeHostConnectionPhase, +): SessionCollaborationImportPhase { + switch (phase) { + case 'discovering': + return 'preparing_route'; + case 'connecting': + return 'connecting'; + case 'authenticating': + return 'authenticating'; + case 'handshaking': + case 'waiting_for_ready': + return 'loading_session'; + } +} + +function reportImportProgress( + observer: ((phase: SessionCollaborationImportPhase) => void) | undefined, + phase: SessionCollaborationImportPhase, +): void { + try { + observer?.(phase); + } catch { + // Presentation progress cannot control the import lifecycle. + } +} + +function requireOperationId(value: unknown): string { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value)) { + throw new Error('Shared Session import operation ID is invalid'); + } + return value; +} + function waitForDelay(delayMs: number, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(signal.reason); return new Promise((resolve, reject) => { diff --git a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts index 47253123eb..017f505119 100644 --- a/apps/desktop/src/main/runtime-host-peer-mesh-management.ts +++ b/apps/desktop/src/main/runtime-host-peer-mesh-management.ts @@ -18,8 +18,13 @@ */ import type { IpcMain } from 'electron'; -import { LOCAL_RUNTIME_HOST_PROFILE } from '@maka/runtime-host/client'; -import type { PeerMeshNode } from '@maka/runtime-host/peer-mesh'; +import { + abortable, + LOCAL_RUNTIME_HOST_PROFILE, + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; +import { PeerMeshPostCommitError, type PeerMeshNode } from '@maka/runtime-host/peer-mesh'; import { decodePeerMeshInvitation, type PeerMeshInvitationV1, @@ -46,6 +51,7 @@ type SshTerminal = ReturnType; type LocalOperator = ReturnType; type PeerMeshAction = DesktopRuntimeHostSshPeerMeshManagementInput['action']; type PeerMeshResult = PeerMeshQueryResult | PeerMeshInvitationResult; +type PeerMeshManagementResultFrame = Awaited>; interface ManagedPeerMeshCommand { readonly action: PeerMeshAction; @@ -56,8 +62,21 @@ interface ManagedPeerMeshCommand { readonly signal?: AbortSignal; } +interface ActivePeerMeshOperation { + readonly controller: AbortController; + readonly cancellable: boolean; + readonly abortOnClose: boolean; +} + type RunManagedPeerMeshCommand = (command: ManagedPeerMeshCommand) => Promise; +class PeerMeshMutationOutcomeUnknownError extends Error { + constructor(options: ErrorOptions = {}) { + super('Peer Mesh mutation outcome is unknown', options); + this.name = 'PeerMeshMutationOutcomeUnknownError'; + } +} + export function createDesktopRuntimeHostPeerMeshManagement(input: { readonly ipcMain: Pick; readonly localMesh?: () => PeerMeshNode | undefined; @@ -69,7 +88,7 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { readonly profiles: Pick; readonly runRemote: SshTerminal['runPeerMeshManagement']; }): { close(): void } { - const activeOperations = new Map(); + const activeOperations = new Map(); const execute = async ( targetValue: unknown, actionValue: unknown, @@ -130,15 +149,14 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { return input.localHost.inspectManaged(async (managed) => { const run: RunManagedPeerMeshCommand = async (command) => { const { invitation, ...rest } = command; - const response = await input.runLocal({ - operatorPath: managed.operatorPath, - target: managedTarget(managed), - ...rest, - ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), - signal: command.signal, - }); - if (response.kind === 'error') throw new Error(response.error.message); - return response.result; + return runFramedPeerMeshCommand(command.action, () => + input.runLocal({ + operatorPath: managed.operatorPath, + target: managedTarget(managed), + ...rest, + ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + signal: command.signal, + })); }; return executeManagedTarget( input.localMesh?.(), @@ -177,25 +195,21 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { const transport = managed.profile.transport; const run: RunManagedPeerMeshCommand = async (command) => { const { invitation, ...rest } = command; - const response = await input.runRemote({ - destination: transport.destination, - ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), - operatorPath: managed.control.operatorPath, - expectedTarget: { - serviceId: managed.deployment.id, - rootPath: managed.deployment.rootPath, - rootId: managed.profile.rootId, - deploymentId: managed.deployment.deploymentId, - }, - ...rest, - ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), - signal: command.signal, - }); - if (response.kind === 'error') throw new Error(response.error.message); - if (response.action !== command.action) { - throw new Error('Runtime Host returned an unrelated Mesh result'); - } - return response.result; + return runFramedPeerMeshCommand(command.action, () => + input.runRemote({ + destination: transport.destination, + ...(transport.sshPort === undefined ? {} : { sshPort: transport.sshPort }), + operatorPath: managed.control.operatorPath, + expectedTarget: { + serviceId: managed.deployment.id, + rootPath: managed.deployment.rootPath, + rootId: managed.profile.rootId, + deploymentId: managed.deployment.deploymentId, + }, + ...rest, + ...(invitation ? { invitation: JSON.stringify(invitation) } : {}), + signal: command.signal, + })); }; return executeManagedTarget( input.localMesh?.(), @@ -214,22 +228,33 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { channel, async (_event, target, action, meshId, peerId, invitation, displayName, operationIdValue) => { const operationId = requireOperationId(operationIdValue); - if (!operationId) { - return execute(target, action, meshId, peerId, invitation, displayName); - } + const parsedTarget = requireTarget(target); + const parsedAction = requireAction(action); const controller = new AbortController(); if (activeOperations.has(operationId)) throw new Error('Peer Mesh operation is already active'); - activeOperations.set(operationId, controller); + activeOperations.set(operationId, { + controller, + cancellable: canCancelOperation(parsedTarget, parsedAction), + abortOnClose: parsedAction === 'status', + }); try { - return await execute( - target, - action, - meshId, - peerId, - invitation, - displayName, - controller.signal, - ); + try { + return { + kind: 'completed' as const, + result: await execute( + parsedTarget, + parsedAction, + meshId, + peerId, + invitation, + displayName, + controller.signal, + ), + }; + } catch (error) { + if (mutationOutcomeIsUnknown(error)) return { kind: 'outcome_unknown' as const }; + throw error; + } } finally { activeOperations.delete(operationId); } @@ -237,14 +262,16 @@ export function createDesktopRuntimeHostPeerMeshManagement(input: { ); const cancelChannel = 'runtime-host-peer-mesh:cancel'; input.ipcMain.handle(cancelChannel, (_event, operationIdValue) => { - const operationId = requireOperationId(operationIdValue, true); - if (!operationId) throw new Error('Peer Mesh operation ID is required'); - activeOperations.get(operationId)?.abort(new Error('Peer Mesh operation was cancelled')); + const operationId = requireOperationId(operationIdValue); + const operation = activeOperations.get(operationId); + if (operation?.cancellable) { + operation.controller.abort(new Error('Peer Mesh operation was cancelled')); + } }); return { close: () => { - for (const controller of activeOperations.values()) { - controller.abort(new Error('Peer Mesh management closed')); + for (const { abortOnClose, controller } of activeOperations.values()) { + if (abortOnClose) controller.abort(new Error('Peer Mesh management closed')); } activeOperations.clear(); input.ipcMain.removeHandler(channel); @@ -280,7 +307,10 @@ function runLivePeerMeshCommand( ): Promise { switch (command.action) { case 'status': - return client.request('peer.mesh.query', {}); + return abortable( + () => client.request('peer.mesh.query', {}, LIVE_HOST_QUERY_TIMEOUT_MS), + command.signal, + ); case 'create': return client.request('peer.mesh.create', {}); case 'invite': @@ -320,6 +350,52 @@ function runLivePeerMeshCommand( } } +async function runFramedPeerMeshCommand( + action: PeerMeshAction, + run: () => Promise, +): Promise { + let response: PeerMeshManagementResultFrame; + try { + response = await run(); + } catch (error) { + if (action !== 'status') throw new PeerMeshMutationOutcomeUnknownError({ cause: error }); + throw error; + } + if (response.action !== action) { + throw new Error('Runtime Host returned an unrelated Mesh result'); + } + if (response.kind === 'error') { + if (response.error.code === 'commit_outcome_unknown') { + throw new PeerMeshMutationOutcomeUnknownError({ cause: new Error(response.error.message) }); + } + throw new Error(response.error.message); + } + return response.result; +} + +function mutationOutcomeIsUnknown(error: unknown): boolean { + if ( + error instanceof PeerMeshMutationOutcomeUnknownError || + error instanceof PeerMeshPostCommitError || + (error instanceof RuntimeHostOperationError && error.code === 'commit_outcome_unknown') || + (error instanceof RuntimeHostRequestInterruptedError && + error.mode === 'command' && + error.dispatch === 'dispatched') + ) { + return true; + } + return error instanceof AggregateError && error.errors.some(mutationOutcomeIsUnknown); +} + +const LIVE_HOST_QUERY_TIMEOUT_MS = 10_000; + +function canCancelOperation( + target: DesktopRuntimeHostPeerMeshTarget, + action: PeerMeshAction, +): boolean { + return action === 'status' || (target.kind === 'desktop' && action === 'join'); +} + async function reconcileDesktopTarget( desktopMesh: PeerMeshNode | undefined, localHost: Pick, @@ -550,8 +626,7 @@ function requireIdentifier(value: unknown, label: string): string { return value; } -function requireOperationId(value: unknown, required = false): string | undefined { - if (value === undefined && !required) return undefined; +function requireOperationId(value: unknown): string { if ( typeof value !== 'string' || value.length === 0 || diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 20d8dd908c..ce1ab70ce2 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -136,6 +136,8 @@ import type { TestProxyInput } from '@maka/core/settings/network-settings'; import type { ExternalSessionImportIpcResult } from './external-session-import-result.js'; import type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; import type { + SessionCollaborationCancelResult, + SessionCollaborationImportPhase, SessionCollaborationImportResult, SessionCollaborationMountSummary, } from '../shared/session-collaboration.js'; @@ -334,6 +336,10 @@ export type DesktopSessionCollaborationImportResult = SessionCollaborationImport export type DesktopGuestSessionMountSummary = SessionCollaborationMountSummary; +export type DesktopSessionCollaborationImportPhase = SessionCollaborationImportPhase; + +export type DesktopSessionCollaborationCancelResult = SessionCollaborationCancelResult; + export type DesktopSessionCollaborationPrepareResult = | { readonly kind: 'prepared'; @@ -574,6 +580,10 @@ export type DesktopRuntimeHostPeerMeshResult = | import('@maka/runtime-host/protocol').PeerMeshQueryResult | import('@maka/runtime-host/protocol').PeerMeshInvitationResult; +export type DesktopRuntimeHostPeerMeshExecutionOutcome = + | { readonly kind: 'completed'; readonly result: DesktopRuntimeHostPeerMeshResult } + | { readonly kind: 'outcome_unknown' }; + type RuntimeHostUpdatePolicyResult = Extract< RuntimeHostServiceManagementFrame, { kind: 'result'; action: 'update_policy' } @@ -705,7 +715,9 @@ export interface MakaBridge { importInvitation(input: { readonly code: string; readonly allowInsecure?: boolean; - }): Promise; + readonly operationId: string; + }, onProgress?: (phase: DesktopSessionCollaborationImportPhase) => void): Promise; + cancelImport(operationId: string): Promise; listMounts(): Promise; removeMount(mountId: string): Promise; requestTurn( @@ -824,14 +836,14 @@ export interface MakaBridge { execute( target: DesktopRuntimeHostPeerMeshTarget, action: DesktopRuntimeHostPeerMeshAction, - input?: { + input: { readonly meshId?: string | null; readonly peerId?: string; readonly invitation?: string; readonly displayName?: string | null; - readonly operationId?: string; + readonly operationId: string; }, - ): Promise; + ): Promise; cancel(operationId: string): Promise; }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bbe0af9c28..48503d5716 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1282,8 +1282,28 @@ const makaBridge = { grantId, ); }, - importInvitation({ code, allowInsecure = false }) { - return ipcRenderer.invoke('session-collaboration:import', code, allowInsecure); + async importInvitation({ code, allowInsecure = false, operationId }, onProgress) { + const listener = ( + _event: Electron.IpcRendererEvent, + progressOperationId: string, + phase: Parameters>[0], + ) => { + if (progressOperationId === operationId) onProgress?.(phase); + }; + ipcRenderer.on('session-collaboration:import:progress', listener); + try { + return await ipcRenderer.invoke( + 'session-collaboration:import', + code, + allowInsecure, + operationId, + ); + } finally { + ipcRenderer.off('session-collaboration:import:progress', listener); + } + }, + cancelImport(operationId) { + return ipcRenderer.invoke('session-collaboration:import:cancel', operationId); }, listMounts() { return ipcRenderer.invoke('session-collaboration:mount:list'); @@ -1547,8 +1567,8 @@ const makaBridge = { readonly peerId?: string; readonly invitation?: string; readonly displayName?: string | null; - readonly operationId?: string; - } = {}, + readonly operationId: string; + }, ) { return ipcRenderer.invoke( 'runtime-host-peer-mesh:execute', diff --git a/apps/desktop/src/renderer/features/runtime-host-management/index.ts b/apps/desktop/src/renderer/features/runtime-host-management/index.ts index 0e0eacb49d..f8a658bc7a 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/index.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/index.ts @@ -25,4 +25,5 @@ export { } from './ui/runtime-host-profile-pairing-actions'; export type { RuntimeHostPairingActionCopy } from './ui/runtime-host-profile-pairing-actions'; export { RuntimeHostManagementServicesProvider } from './services-context'; +export { PeerMeshOperationOutcomeUnknownError } from './ports'; export type { RuntimeHostManagementServices } from './ports'; diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts index 325564d367..cf93e2e5f8 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/ports.ts +++ b/apps/desktop/src/renderer/features/runtime-host-management/ports.ts @@ -33,7 +33,7 @@ export interface PeerMeshOperationInput { readonly peerId?: string; readonly invitation?: string; readonly displayName?: string | null; - readonly operationId?: string; + readonly operationId: string; } export interface PeerMeshDirectPeerSnapshot { @@ -48,11 +48,18 @@ export interface PeerMeshDirectPeerSnapshot { readonly managementAvailable: boolean; } +export class PeerMeshOperationOutcomeUnknownError extends Error { + constructor(readonly action: RuntimeHostPeerMeshManagementAction) { + super('Peer Mesh operation outcome is unknown'); + this.name = 'PeerMeshOperationOutcomeUnknownError'; + } +} + export interface PeerMeshServices { execute( target: PeerMeshTarget, action: RuntimeHostPeerMeshManagementAction, - input?: PeerMeshOperationInput, + input: PeerMeshOperationInput, ): Promise; cancel(operationId: string): Promise; getDirectPeer(profileId: string): Promise; diff --git a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx index fe87f68137..2979746413 100644 --- a/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx +++ b/apps/desktop/src/renderer/features/runtime-host-management/ui/runtime-host-peer-mesh-dialog.tsx @@ -51,9 +51,10 @@ import { Workflow, } from '@maka/ui/icons'; import { useRuntimeHostManagementServices } from '../services-context.js'; -import type { - PeerMeshDirectPeerSnapshot, - PeerMeshTarget, +import { + PeerMeshOperationOutcomeUnknownError, + type PeerMeshDirectPeerSnapshot, + type PeerMeshTarget, } from '../ports.js'; type PeerMeshDialogView = @@ -88,6 +89,11 @@ type LocalHostAvailability = | { readonly kind: 'unavailable' } | { readonly kind: 'available'; readonly peerId: string }; +interface ActivePeerMeshOperation { + readonly operationId: string; + readonly cancellable: boolean; +} + const LOCAL_HOST_TARGET = { kind: 'local_host' } as const; export function RuntimeHostPeerMeshDialog(props: { @@ -106,12 +112,15 @@ export function RuntimeHostPeerMeshDialog(props: { const [view, setView] = useState({ kind: 'overview' }); const [error, setError] = useState(); const [workingAction, setWorkingAction] = useState(); + const [settling, setSettling] = useState(false); + const [operationCancellable, setOperationCancellable] = useState(false); const [managedHostPeerSetup, setManagedHostPeerSetup] = useState( props.target.kind === 'managed_host' ? { kind: 'loading' } : { kind: 'idle' }, ); const working = workingAction !== undefined; - const activeOperationId = useRef(undefined); - const cancelledOperationId = useRef(undefined); + const activeOperation = useRef(undefined); + const cancelRequestedOperationId = useRef(undefined); + const closeRequested = useRef(false); const statusOperationIds = useRef(new Set()); const refreshSequence = useRef(0); const closed = useRef(false); @@ -166,24 +175,27 @@ export function RuntimeHostPeerMeshDialog(props: { const refresh = useCallback(async () => { if (closed.current) return; const sequence = ++refreshSequence.current; - const [result, localHost] = await Promise.all([ - executeStatus(activeTarget), - offerLocalHost - ? executeStatus(LOCAL_HOST_TARGET).then( - (value) => ({ kind: 'result' as const, value }), - () => ({ kind: 'failed' as const }), - ) - : undefined, - ]); + const result = await executeStatus(activeTarget); if (!isSnapshot(result)) throw new Error(copy.invalidResult); if (closed.current || sequence !== refreshSequence.current) return; setSnapshot(result); setError(undefined); if (offerLocalHost) { - setLocalHost( - localHost?.kind === 'result' && isSnapshot(localHost.value) && localHost.value.localPeerId - ? { kind: 'available', peerId: localHost.value.localPeerId } - : { kind: 'unavailable' }, + setLocalHost({ kind: 'loading' }); + void executeStatus(LOCAL_HOST_TARGET).then( + (localHost) => { + if (closed.current || sequence !== refreshSequence.current) return; + setLocalHost( + isSnapshot(localHost) && localHost.localPeerId + ? { kind: 'available', peerId: localHost.localPeerId } + : { kind: 'unavailable' }, + ); + }, + () => { + if (!closed.current && sequence === refreshSequence.current) { + setLocalHost({ kind: 'unavailable' }); + } + }, ); } }, [activeTarget, copy.invalidResult, executeStatus, offerLocalHost]); @@ -202,8 +214,6 @@ export function RuntimeHostPeerMeshDialog(props: { closed.current = true; refreshSequence.current += 1; cancelStatusOperations(); - const operationId = activeOperationId.current; - if (operationId) void services.cancel(operationId); }; }, [cancelStatusOperations, copy.unknownError, offerLocalHost, refresh]); @@ -246,28 +256,82 @@ export function RuntimeHostPeerMeshDialog(props: { async function runOperation( action: PeerMeshWorkingAction, operation: (operationId: string) => Promise, + policy: { + readonly cancellable?: boolean; + readonly preserveResultOnClose?: boolean; + } = {}, ): Promise { if (closed.current) return false; const operationId = services.createOperationId(); - activeOperationId.current = operationId; + activeOperation.current = { operationId, cancellable: policy.cancellable === true }; + cancelRequestedOperationId.current = undefined; + setSettling(false); + setOperationCancellable(policy.cancellable === true); setWorkingAction(action); setError(undefined); let completed = false; - let cancelled = false; + let unknownOutcome = false; + let unknownOutcomeReconciled = false; try { await operation(operationId); completed = true; } catch (failure) { - if (!closed.current && cancelledOperationId.current !== operationId) { + if ( + failure instanceof PeerMeshOperationOutcomeUnknownError && + !closed.current && + cancelRequestedOperationId.current !== operationId + ) { + unknownOutcome = true; + setSettling(true); + setOperationCancellable(false); + try { + await refresh(); + unknownOutcomeReconciled = true; + setError( + failure.action === 'invite' + ? copy.invitationOutcomeUnknown + : copy.outcomeUnknown, + ); + } catch { + if (!closed.current) setError(copy.outcomeUnknownRefreshFailed); + } + } else if (!closed.current && cancelRequestedOperationId.current !== operationId) { setError(peerMeshErrorMessage(failure, copy.unknownError)); } } finally { - cancelled = cancelledOperationId.current === operationId; - if (activeOperationId.current === operationId) activeOperationId.current = undefined; - if (cancelled) cancelledOperationId.current = undefined; - if (!closed.current) setWorkingAction(undefined); + const cancelled = cancelRequestedOperationId.current === operationId; + if (cancelled && !closed.current) { + try { + await refresh(); + } catch (failure) { + if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); + } + } + if (cancelRequestedOperationId.current === operationId) { + cancelRequestedOperationId.current = undefined; + } + if (activeOperation.current?.operationId === operationId) activeOperation.current = undefined; + if (cancelled) completed = false; + if (!closed.current && activeOperation.current === undefined) { + setWorkingAction(undefined); + setSettling(false); + setOperationCancellable(false); + } + if (!closed.current && closeRequested.current) { + if (unknownOutcome && !unknownOutcomeReconciled) closeRequested.current = false; + else if (completed && policy.preserveResultOnClose) closeRequested.current = false; + else finishClose(); + } } - return completed && !cancelled && !closed.current; + return completed && !closed.current; + } + + function operationIsCurrent(operationId: string): boolean { + return ( + !closed.current && + activeOperation.current?.operationId === operationId && + cancelRequestedOperationId.current !== operationId + ); } async function refreshNow(): Promise { @@ -284,20 +348,38 @@ export function RuntimeHostPeerMeshDialog(props: { } function cancelOperation(): void { - const operationId = activeOperationId.current; - if (operationId) { - cancelledOperationId.current = operationId; - void services.cancel(operationId); - } + const operation = activeOperation.current; + if ( + !operation?.cancellable || + cancelRequestedOperationId.current === operation.operationId + ) return; + const operationId = operation.operationId; + cancelRequestedOperationId.current = operationId; + if (!closed.current) setSettling(true); + void services.cancel(operationId); } - function requestClose(): void { + function finishClose(): void { + if (closed.current) return; closed.current = true; - if (working) cancelOperation(); cancelStatusOperations(); props.onClose(); } + function requestClose(): void { + if (workingAction && workingAction !== 'refresh') { + if (activeOperation.current?.cancellable) { + cancelOperation(); + finishClose(); + return; + } + closeRequested.current = true; + setSettling(true); + return; + } + finishClose(); + } + async function createMesh(): Promise { await runOperation('create', async (operationId) => { const previousMeshIds = new Set(snapshot?.meshes.map(({ meshId }) => meshId)); @@ -305,49 +387,62 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); const created = result.meshes.find(({ meshId }) => !previousMeshIds.has(meshId)); if (created && offerLocalHost) { await joinLocalHost(created.meshId, operationId); + if (!operationIsCurrent(operationId)) return; await refresh(); } }); } async function join(): Promise { - await runOperation('join', async (operationId) => { - const result = await services.execute(activeTarget, 'join', { - invitation: joinDraft.trim(), - operationId, - }); - if (!isSnapshot(result)) throw new Error(copy.invalidResult); - setJoinDraft(''); - setView({ kind: 'overview' }); - setSnapshot(result); - }); + await runOperation( + 'join', + async (operationId) => { + const result = await services.execute(activeTarget, 'join', { + invitation: joinDraft.trim(), + operationId, + }); + if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; + setJoinDraft(''); + setView({ kind: 'overview' }); + setSnapshot(result); + }, + { cancellable: activeTarget.kind === 'desktop' }, + ); } async function createInvitation(meshId: string): Promise { - await runOperation('invite', async (operationId) => { - const result = await services.execute(activeTarget, 'invite', { - meshId, - operationId, - }); - if (!isInvitationResult(result)) throw new Error(copy.invalidResult); - setView({ - kind: 'invitation', - meshId, - code: JSON.stringify(result.invitation), - expiresAt: result.invitation.expiresAt, - hasCoordinationRelay: result.invitation.coordinationRelays.length > 0, - }); - setSnapshot(result.snapshot); - }); + await runOperation( + 'invite', + async (operationId) => { + const result = await services.execute(activeTarget, 'invite', { + meshId, + operationId, + }); + if (!isInvitationResult(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; + setView({ + kind: 'invitation', + meshId, + code: JSON.stringify(result.invitation), + expiresAt: result.invitation.expiresAt, + hasCoordinationRelay: result.invitation.coordinationRelays.length > 0, + }); + setSnapshot(result.snapshot); + }, + { preserveResultOnClose: true }, + ); } async function addLocalHost(meshId: string): Promise { await runOperation('add-host', async (operationId) => { await joinLocalHost(meshId, operationId); + if (!operationIsCurrent(operationId)) return; await refresh(); }); } @@ -369,7 +464,10 @@ export function RuntimeHostPeerMeshDialog(props: { } catch (failure) { if (!closed.current) setError(peerMeshErrorMessage(failure, copy.unknownError)); } finally { - if (!closed.current) setWorkingAction(undefined); + if (!closed.current) { + setWorkingAction(undefined); + if (closeRequested.current) finishClose(); + } } } @@ -379,7 +477,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isInvitationResult(prepared)) throw new Error(copy.invalidResult); - if (cancelledOperationId.current === operationId) { + if (!operationIsCurrent(operationId)) { throw new Error('Peer Mesh operation was cancelled'); } const joined = await services.execute( @@ -388,6 +486,9 @@ export function RuntimeHostPeerMeshDialog(props: { { invitation: JSON.stringify(prepared.invitation), operationId }, ); if (!isSnapshot(joined)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) { + throw new Error('Peer Mesh operation was cancelled'); + } } async function mutate( @@ -415,6 +516,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); } @@ -426,6 +528,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); } @@ -447,6 +550,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); if (!completed) throw new Error('Peer Mesh rename did not complete'); @@ -460,6 +564,7 @@ export function RuntimeHostPeerMeshDialog(props: { operationId, }); if (!isSnapshot(result)) throw new Error(copy.invalidResult); + if (!operationIsCurrent(operationId)) return; setSnapshot(result); }); if (!completed) throw new Error('Peer Mesh rename did not complete'); @@ -538,9 +643,9 @@ export function RuntimeHostPeerMeshDialog(props: { {workingAction ? ( ; + readonly operationId: string; + }, onProgress?: (phase: SessionCollaborationImportPhase) => void): Promise; + cancelImport(operationId: string): Promise; listMounts(): Promise; removeMount(mountId: string): Promise; + createOperationId(): string; } diff --git a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx index ef5aa296a7..414158f9ed 100644 --- a/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx +++ b/apps/desktop/src/renderer/features/session-collaboration/ui/session-collaboration-join-dialog.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { List, ListItem } from '@astryxdesign/core/List'; @@ -29,7 +29,10 @@ import { useToast, } from '@maka/ui'; import { useSessionCollaborationServices } from '../services-context.js'; -import type { SessionCollaborationMountSummary } from '../ports.js'; +import type { + SessionCollaborationImportPhase, + SessionCollaborationMountSummary, +} from '../ports.js'; export interface SessionCollaborationJoinCopy { readonly joinTitle: string; @@ -43,7 +46,13 @@ export interface SessionCollaborationJoinCopy { readonly directPathUnavailable: string; readonly code: string; readonly join: string; - readonly joining: string; + readonly validatingInvitation: string; + readonly discoveringHost: string; + readonly preparingRoute: string; + readonly connectingHost: string; + readonly authenticatingGuest: string; + readonly finalizingAccess: string; + readonly loadingSession: string; readonly retainedTasks: string; readonly disconnect: string; readonly disconnectFailed: string; @@ -59,15 +68,19 @@ export function SessionCollaborationJoinDialog(props: { const [code, setCode] = useState(''); const [mounts, setMounts] = useState([]); const [removingMountId, setRemovingMountId] = useState(); + const activeOperationId = useRef(undefined); + const closeAfterSettlement = useRef(false); + const open = useRef(true); const [joinState, setJoinState] = useState< | { readonly kind: 'idle' } - | { readonly kind: 'working' } + | { readonly kind: 'working'; readonly phase: SessionCollaborationImportPhase } | { readonly kind: 'failed'; readonly message: string } >({ kind: 'idle' }); const working = joinState.kind === 'working'; const failure = joinState.kind === 'failed' ? joinState.message : undefined; useEffect(() => { + open.current = true; let disposed = false; void services.listMounts().then( (next) => { @@ -77,17 +90,33 @@ export function SessionCollaborationJoinDialog(props: { ); return () => { disposed = true; + open.current = false; + const operationId = activeOperationId.current; + if (operationId) void services.cancelImport(operationId); }; }, [services]); async function join(allowInsecure = false): Promise { - setJoinState({ kind: 'working' }); + const operationId = services.createOperationId(); + activeOperationId.current = operationId; + closeAfterSettlement.current = false; + setJoinState({ kind: 'working', phase: 'validating_invitation' }); try { const result = await services.importInvitation({ code: code.trim(), allowInsecure, + operationId, + }, (phase) => { + if (open.current && activeOperationId.current === operationId) { + setJoinState({ kind: 'working', phase }); + } }); + if (!open.current || activeOperationId.current !== operationId) return; if (result.kind === 'error' && result.reason === 'insecure_confirmation_required') { + if (closeAfterSettlement.current) { + finishClose(); + return; + } const confirmed = await toast.confirm({ title: props.copy.insecureTitle, description: props.copy.insecureBody, @@ -95,23 +124,65 @@ export function SessionCollaborationJoinDialog(props: { cancelLabel: props.copy.close, destructive: true, }); - if (confirmed) await join(true); + if (confirmed && open.current && activeOperationId.current === operationId) { + await join(true); + } return; } if (result.kind === 'error') { + if (closeAfterSettlement.current) { + finishClose(); + return; + } const message = importError(props.copy, result.reason, result.message); setJoinState({ kind: 'failed', message }); toast.error(props.copy.joinTitle, message); return; } props.onImported(); - props.onClose(); + finishClose(); } catch (error) { + if (!open.current || activeOperationId.current !== operationId) return; + if (closeAfterSettlement.current) { + finishClose(); + return; + } const message = errorMessage(error); setJoinState({ kind: 'failed', message }); toast.error(props.copy.joinTitle, message); } finally { - setJoinState((current) => current.kind === 'working' ? { kind: 'idle' } : current); + if (activeOperationId.current === operationId) activeOperationId.current = undefined; + if (open.current) { + setJoinState((current) => current.kind === 'working' ? { kind: 'idle' } : current); + } + } + } + + function finishClose(): void { + open.current = false; + props.onClose(); + } + + async function requestClose(): Promise { + const operationId = activeOperationId.current; + if (!operationId) { + finishClose(); + return; + } + closeAfterSettlement.current = true; + try { + const result = await services.cancelImport(operationId); + if (!open.current || activeOperationId.current !== operationId) return; + if (result === 'settling') { + setJoinState({ kind: 'working', phase: 'finalizing_access' }); + return; + } + finishClose(); + } catch (error) { + if (!open.current || activeOperationId.current !== operationId) return; + const message = errorMessage(error); + setJoinState({ kind: 'failed', message }); + toast.error(props.copy.joinTitle, message); } } @@ -130,7 +201,9 @@ export function SessionCollaborationJoinDialog(props: { return ( !open && !working && props.onClose()} + onOpenChange={(nextOpen) => { + if (!nextOpen) void requestClose(); + }} purpose="form" width={560} > @@ -139,13 +212,17 @@ export function SessionCollaborationJoinDialog(props: { !open && !working && props.onClose()} + onOpenChange={(nextOpen) => { + if (!nextOpen) void requestClose(); + }} /> )} content={( - {working ? : null} + {joinState.kind === 'working' ? ( + + ) : null} {failure ? ( void requestClose()} />