Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -711,27 +711,35 @@ test('reconnects after a pairing candidate becomes bound to this Client', async
await manager.close();
});

test('activates Guest access on a fresh stream without replaying route progress', async () => {
test('completes Guest import at credential activation while reconnect continues', async () => {
const local = candidateHarness({ hostId: 'host-a' });
const remoteHostId = 'a'.repeat(64);
const pending = candidateHarness({
hostId: remoteHostId,
finalizeReconnectRequired: true,
});
const active = candidateHarness({ hostId: remoteHostId });
const queue = [local.candidate, pending.candidate, active.candidate];
let releaseActive!: () => void;
const activeReleased = new Promise<void>((resolve) => {
releaseActive = resolve;
});
let starts = 0;
const phases: string[] = [];
const routeRefreshes: Array<boolean | undefined> = [];
const manager = await startRuntimeHostDesktopManager(
{} as DesktopRuntimeHostCandidateStartInput,
{
startCandidate: async (input) => {
starts += 1;
if (input.profileTarget) {
routeRefreshes.push(input.refreshPeerRoutes);
input.onConnectionPhase?.('discovering');
input.onConnectionPhase?.('connecting');
}
return ready(queue.shift()!);
if (starts === 1) return ready(local.candidate);
if (starts === 2) return ready(pending.candidate);
await activeReleased;
return ready(active.candidate);
},
reconnectBackoff: { minMs: 0, maxMs: 0 },
},
Expand All @@ -743,14 +751,18 @@ test('activates Guest access on a fresh stream without replaying route progress'
);
let activations = 0;

await manager.finalizeGuestAccess('shared-session', undefined, () => {
const result = await manager.finalizeGuestAccess('shared-session', undefined, () => {
activations += 1;
});

assert.equal(result, 'reconnecting');
assert.equal(manager.current('shared-session')?.readiness, 'reconnecting');
assert.deepEqual(phases, ['discovering', 'connecting']);
assert.deepEqual(routeRefreshes, [undefined, false]);
assert.equal(activations, 1);
assert.equal(pending.closeCalls, 1);
releaseActive();
await manager.waitUntilReady('shared-session');
assert.deepEqual(routeRefreshes, [undefined, false]);
assert.equal(manager.current('shared-session')?.candidate, active.candidate);
await manager.close();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test('retains a successful Guest mount and rehydrates the same authority after r
await second!.close();
});

test('presents Guest import as one connection followed by access activation', async () => {
test('reports activated Guest access as recovering while reauthentication continues', async () => {
const progress: string[] = [];
const mounts = service(memoryStore(), {
mount: async (_target, _signal, onConnectionPhase) => {
Expand All @@ -76,6 +76,7 @@ test('presents Guest import as one connection followed by access activation', as
},
finalizeAccess: async (_mountId, _signal, onAccessActivated) => {
onAccessActivated?.();
return 'reconnecting';
},
});

Expand All @@ -86,7 +87,7 @@ test('presents Guest import as one connection followed by access activation', as
(phase) => progress.push(phase),
);

assert.equal(result.kind, 'connected');
assert.equal(result.kind, 'recovering');
const visibleProgress = progress.filter((phase, index) => phase !== progress[index - 1]);
assert.deepEqual(visibleProgress, [
'validating_invitation',
Expand Down Expand Up @@ -202,6 +203,7 @@ test('settles admitted finalization before committing unmount desire', async ()
finalizeAccess: async () => {
started();
await finalized;
return 'ready';
},
unmount: async () => {
assert.deepEqual(await store.read(), []);
Expand Down Expand Up @@ -257,6 +259,7 @@ test('removal fences a connecting startup mount before credential finalization',
},
finalizeAccess: async () => {
finalizations += 1;
return 'ready';
},
});

Expand Down Expand Up @@ -288,6 +291,7 @@ test('removal settles one admitted startup finalization without waiting through
finalizeAccess: async () => {
markFinalizing();
await finalization;
return 'ready';
},
});

Expand Down Expand Up @@ -315,6 +319,7 @@ test('settles admitted finalization before closing and retains the mount', async
finalizeAccess: async () => {
started();
await finalized;
return 'ready';
},
});

Expand Down Expand Up @@ -347,11 +352,12 @@ test('retains and reconciles a mount when finalization outcome is unknown', asyn
attempts += 1;
if (attempts === 1) throw new RuntimeHostPairingFinalizationInterruptedError();
resolveReconciled();
return 'ready';
},
});

const result = await mounts.importInvitation(invitation('guest-unknown'), false, 'import-unknown');
assert.equal(result.kind, 'error');
assert.equal(result.kind, 'recovering');
assert.equal((await store.read()).length, 1);
await reconciled;
assert.equal(attempts, 2);
Expand Down Expand Up @@ -446,7 +452,7 @@ function service(
return createDesktopGuestSessionMountService({
store,
mount: overrides.mount ?? (async () => undefined),
finalizeAccess: overrides.finalizeAccess ?? (async () => undefined),
finalizeAccess: overrides.finalizeAccess ?? (async () => 'ready'),
unmount: overrides.unmount ?? (async () => undefined),
...(overrides.wait ? { wait: overrides.wait } : {}),
onError: overrides.onError ?? (() => undefined),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const originalGlobals = {
Event: globalThis.Event,
Node: globalThis.Node,
CSS: globalThis.CSS,
getComputedStyle: globalThis.getComputedStyle,
matchMedia: globalThis.matchMedia,
requestAnimationFrame: globalThis.requestAnimationFrame,
cancelAnimationFrame: globalThis.cancelAnimationFrame,
Expand Down Expand Up @@ -109,6 +110,57 @@ test('keeps loading progress visible while an irreversible import settles', asyn
assert.doesNotMatch(document.body.textContent, /finalizingAccess/u);
});

test('closes as a retained background recovery instead of reporting a failed join', async () => {
let imported = 0;
let closed = 0;
const services: SessionCollaborationServices = {
importInvitation: async () => ({ kind: 'recovering', mountId: 'shared-1' }),
cancelImport: async () => 'settling',
readInvitationClipboard: async () => '',
listMounts: async () => [],
removeMount: async () => undefined,
getPendingTurnRequests: async () => [],
decideTurnRequest: async () => {
throw new Error('unused');
},
createOperationId: () => 'operation-1',
};
const { document } = installDom();
const container = document.querySelector('#root');
assert.ok(container);
mountedRoot = createRoot(container);
await act(async () => {
mountedRoot?.render(
createElement(LocaleProvider, {
locale: 'en',
children: createElement(AstryxLocaleProvider, {
children: createElement(ToastProvider, {
children: createElement(SessionCollaborationServicesProvider, {
services,
children: createElement(SessionCollaborationJoinDialog, {
copy: testCopy(),
onImported: () => {
imported += 1;
},
onClose: () => {
closed += 1;
},
}),
}),
}),
}),
}),
);
await Promise.resolve();
});
await setTextArea(document, 'invitation');
await clickButton(document, 'join');

assert.equal(imported, 1);
assert.equal(closed, 1);
assert.doesNotMatch(document.body.textContent, /connectionFailed/u);
});

function installDom(): { document: Document } {
const parsed = parseHTML('<html><body><div id="root"></div></body></html>');
const { document, window } = parsed;
Expand All @@ -122,7 +174,10 @@ function installDom(): { document: Document } {
removeEventListener() {},
dispatchEvent: () => false,
});
Object.assign(window, { matchMedia, scrollTo() {} });
const getComputedStyle = () => ({
getPropertyValue: () => '',
}) as unknown as CSSStyleDeclaration;
Object.assign(window, { matchMedia, getComputedStyle, scrollTo() {} });
Object.assign(window.HTMLElement.prototype, {
showModal(this: HTMLElement) {
this.setAttribute('open', '');
Expand All @@ -140,6 +195,7 @@ function installDom(): { document: Document } {
Event: window.Event,
Node: window.Node,
CSS: { escape: (value: string) => value },
getComputedStyle,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0),
cancelAnimationFrame: (handle: number) => clearTimeout(handle),
IS_REACT_ACT_ENVIRONMENT: true,
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ const guestSessionMountService = createDesktopGuestSessionMountService({
},
finalizeAccess: async (mountId, signal, onAccessActivated) => {
if (!runtimeHostManager) throw new Error('Runtime Host manager is unavailable');
await runtimeHostManager.finalizeGuestAccess(mountId, signal, onAccessActivated);
return runtimeHostManager.finalizeGuestAccess(mountId, signal, onAccessActivated);
},
unmount: async (mountId) => {
if (!runtimeHostManager) return;
Expand Down
32 changes: 24 additions & 8 deletions apps/desktop/src/main/runtime-host-desktop-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export interface RuntimeHostDesktopManager {
mountId: string,
signal?: AbortSignal,
onAccessActivated?: () => void,
): Promise<void>;
): Promise<RuntimeHostGuestAccessFinalization>;
unmountGuest(mountId: string): Promise<void>;
wakePeerRecovery(): void;
disable(profileId: string): Promise<void>;
Expand Down Expand Up @@ -175,11 +175,13 @@ export class RuntimeHostUpgradeCancelledError extends RuntimeHostPermanentReconn

export class RuntimeHostPairingFinalizationInterruptedError extends Error {
constructor(options?: ErrorOptions) {
super('Runtime Host pairing finalization was deferred until the next startup', options);
super('Runtime Host pairing finalization is continuing in the background', options);
this.name = 'RuntimeHostPairingFinalizationInterruptedError';
}
}

export type RuntimeHostGuestAccessFinalization = 'ready' | 'reconnecting';

const DEFAULT_PAIRING_FINALIZATION_TIMEOUT_MS = 30_000;

export type RuntimeHostRestartableConflict = Extract<
Expand Down Expand Up @@ -359,24 +361,27 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
}

finalizePairing(profileId: string): Promise<void> {
return this.#mutateTarget(profileId, () => this.#finalizeAccessCredential(profileId));
return this.#mutateTarget(profileId, async () => {
await this.#finalizeAccessCredential(profileId, 'ready');
});
}

finalizeGuestAccess(
mountId: string,
signal?: AbortSignal,
onAccessActivated?: () => void,
): Promise<void> {
): Promise<RuntimeHostGuestAccessFinalization> {
return this.#mutateTarget(mountId, () =>
this.#finalizeAccessCredential(mountId, signal, onAccessActivated),
this.#finalizeAccessCredential(mountId, 'activation', signal, onAccessActivated),
);
}

async #finalizeAccessCredential(
profileId: string,
completion: 'activation' | 'ready',
externalSignal?: AbortSignal,
onAccessActivated?: () => void,
): Promise<void> {
): Promise<RuntimeHostGuestAccessFinalization> {
const target = this.#requireTarget(profileId);
if (target.target.profile.kind !== 'remote') {
throw new Error('Only remote Runtime Host profiles can finalize pairing');
Expand Down Expand Up @@ -415,11 +420,22 @@ class RuntimeHostDesktopManagerImpl implements RuntimeHostDesktopManager {
) {
target.skipPeerRouteRefreshOnce = true;
}
if (completion === 'activation') {
try {
await candidate.close();
} catch (error) {
// The Host already committed the credential. Candidate cleanup
// cannot turn that durable success into a failed Guest import;
// its closed signal still drives the reconnect lifecycle.
this.#baseInput.onError?.(error);
}
return 'reconnecting';
}
await candidate.close();
await this.#waitForReadyCandidate(lifecycle, candidate, signal);
}
signal.throwIfAborted();
return;
if (completion === 'ready') signal.throwIfAborted();
return 'ready';
} catch (error) {
if (pairingFinalizeTimedOut(error)) {
throw new RuntimeHostPairingFinalizationInterruptedError({ cause: error });
Expand Down
Loading