From 4d7527ddcbc2dda8a9cf22edcbf9f673217e2565 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 5 Oct 2026 23:05:13 -0400 Subject: [PATCH 01/12] test(remote-browser): reproduce a client-hosted browser tab left unavailable after a network outage A paired desktop that loses its host for longer than the browser-host lease grace (25s reset cut, or a 100s silent cut the host's heartbeat reaps) tears down its client-hosted pages and never hosts them again, leaving 'Client-hosted browser unavailable'. Red on main. Extends the freezable TCP proxy with a full silent or reset link cut. --- tests/e2e/helpers/freezable-tcp-proxy.ts | 119 ++++++++++++ ...ient-hosted-browser-network-outage.spec.ts | 174 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 tests/e2e/helpers/freezable-tcp-proxy.ts create mode 100644 tests/e2e/paired-client-hosted-browser-network-outage.spec.ts diff --git a/tests/e2e/helpers/freezable-tcp-proxy.ts b/tests/e2e/helpers/freezable-tcp-proxy.ts new file mode 100644 index 000000000000..4c4b4d3bb89b --- /dev/null +++ b/tests/e2e/helpers/freezable-tcp-proxy.ts @@ -0,0 +1,119 @@ +import net from 'node:net' + +export type FreezableTcpProxy = { + port: number + /** Connection open/close timestamps, for diagnosing client reconnect behaviour. */ + events: string[] + /** Silently stops forwarding on every open connection while keeping it open, like a NAT drop. */ + freezeExisting: (direction?: 'both' | 'to-client') => number + /** + * Takes the whole link down until `restore`. `silent` freezes open connections and holds new ones + * unanswered, like a sleeping laptop; `reset` closes everything and refuses new connections. + */ + cut: (mode: 'silent' | 'reset') => void + /** Ends a `cut`: drops connections that lived through it, as a returning network does, and relays new ones. */ + restore: () => void + close: () => Promise +} + +type ProxyPair = { client: net.Socket; upstream: net.Socket | null; frozen: boolean } + +/** A loopback TCP proxy whose established connections can go half-open while new ones still pass. */ +export async function startFreezableTcpProxy( + targetHost: string, + targetPort: number +): Promise { + const pairs = new Set() + const events: string[] = [] + let nextId = 0 + let cutMode: 'silent' | 'reset' | null = null + const destroyPair = (pair: ProxyPair): void => { + pair.client.destroy() + pair.upstream?.destroy() + pairs.delete(pair) + } + const server = net.createServer((client) => { + const id = nextId++ + events.push(`${Date.now()} open#${id}${cutMode ? ` (during ${cutMode} cut)` : ''}`) + client.on('close', () => events.push(`${Date.now()} close#${id}`)) + client.on('error', () => undefined) + if (cutMode === 'reset') { + client.destroy() + return + } + if (cutMode === 'silent') { + // Why held, not refused: an unreachable host answers nothing; the dial must time out on its own. + client.pause() + pairs.add({ client, upstream: null, frozen: true }) + return + } + const upstream = net.connect(targetPort, targetHost) + const pair: ProxyPair = { client, upstream, frozen: false } + pairs.add(pair) + client.pipe(upstream) + upstream.pipe(client) + const end = (): void => { + // Why: a frozen pair must not relay a close either; the far side would learn of the drop. + if (!pair.frozen) { + destroyPair(pair) + } + } + client.on('close', end) + upstream.on('close', end) + upstream.on('error', () => undefined) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Proxy did not bind a TCP port') + } + const freezeExisting = (direction: 'both' | 'to-client' = 'both'): number => { + let frozen = 0 + for (const pair of pairs) { + if (pair.frozen || !pair.upstream) { + continue + } + pair.frozen = true + // Why one-way: requests still reach the host while its replies and publications vanish. + pair.upstream.unpipe(pair.client) + pair.upstream.pause() + if (direction === 'both') { + pair.client.unpipe(pair.upstream) + pair.client.pause() + } + frozen += 1 + } + return frozen + } + return { + port: address.port, + events, + freezeExisting, + cut: (mode) => { + cutMode = mode + events.push(`${Date.now()} cut:${mode}`) + if (mode === 'silent') { + freezeExisting('both') + return + } + for (const pair of pairs) { + destroyPair(pair) + } + }, + restore: () => { + cutMode = null + events.push(`${Date.now()} restore`) + for (const pair of pairs) { + if (pair.frozen) { + destroyPair(pair) + } + } + }, + close: async () => { + for (const pair of pairs) { + destroyPair(pair) + } + await new Promise((resolve) => server.close(() => resolve())) + } + } +} diff --git a/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts b/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts new file mode 100644 index 000000000000..0f483141cf6e --- /dev/null +++ b/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts @@ -0,0 +1,174 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' +import { + launchPairedElectronClient, + type PairedElectronClient +} from './helpers/paired-electron-client' +import { + findMirroredBrowserPage, + focusClientBrowserRow, + navigateGuest, + readClientBrowserRows, + openClientHostedFixturePage, + readClientWebviewMarker, + selectPairedWorktreeGroup, + startClientHostedMarkerFixture, + waitForPairedWorktreeId, + waitForRenderedClientWebview +} from './helpers/client-hosted-browser-fixture' +import { startFreezableTcpProxy, type FreezableTcpProxy } from './helpers/freezable-tcp-proxy' +import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing' + +/** + * Long enough for the host to void the lease. A reset outage starts the 15s lease grace at once; a + * silent one first needs the host's heartbeat to reap the socket (three missed 15s probes). + */ +const OUTAGE_MS = { reset: 25_000, silent: 100_000 } as const + +async function readPageHostGeneration( + client: PairedElectronClient, + localPageId: string +): Promise { + return client.page.evaluate((pageId) => { + const placement = window.__store?.getState().remoteBrowserPageHandlesByPageId[pageId]?.placement + return placement?.kind === 'client' ? placement.pageHostGeneration : null + }, localPageId) +} + +/** Leaves the browser tab for the terminal and comes back, so the pane remounts its guest. */ +async function remountBrowserTab( + page: Page, + worktreeId: string, + localPageId: string +): Promise { + await page.evaluate((worktreeId) => { + const state = window.__store!.getState() + const terminal = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'terminal' + ) + if (terminal) { + state.activateTab(terminal.id) + } + }, worktreeId) + await page.waitForTimeout(1_000) + await focusClientBrowserRow(page, worktreeId, localPageId) + await page.waitForTimeout(3_000) + return page.getByText('Client-hosted browser unavailable').count() +} + +/** + * Neither Orca process restarts; only the network between them goes away for longer than the lease + * grace. When it returns, the same desktop must host its page again on its own, instead of leaving + * the tab on "Client-hosted browser unavailable … attached to a different desktop" for good. + */ +for (const mode of ['silent', 'reset'] as const) { + test(`the same desktop re-hosts its browser tab after a ${mode} network outage longer than the lease grace`, async ({ + testRepoPath + }, testInfo) => { + test.setTimeout(420_000) + const fixture = await startClientHostedMarkerFixture({ created: 'outage-survivor', moved: 'x' }) + const host = await launchHeadlessPairedRuntimeHost() + let client: PairedElectronClient | null = null + let proxy: FreezableTcpProxy | null = null + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + const decoded = decodePairingOffer(host.offer.pairingUrl) + if (!('endpoint' in decoded)) { + throw new Error('expected a direct pairing offer') + } + const endpoint = new URL(decoded.endpoint) + proxy = await startFreezableTcpProxy(endpoint.hostname, Number(endpoint.port)) + endpoint.port = String(proxy.port) + client = await launchPairedElectronClient( + { pairingUrl: encodePairingOffer({ ...decoded, endpoint: endpoint.toString() }) }, + testInfo, + `client-hosted ${mode} outage` + ) + const worktreeId = await waitForPairedWorktreeId(client.page, testRepoPath) + await selectPairedWorktreeGroup(client.page, client.environmentId, worktreeId) + const opened = await openClientHostedFixturePage(client, worktreeId, fixture.markerUrl) + expect( + await waitForRenderedClientWebview( + client.page, + { urlPrefix: fixture.markerUrl, remotePageId: opened.remotePageId }, + 'client-hosted guest never rendered the fixture' + ) + ).toBe('outage-survivor') + const hostPid = host.app.process().pid + const generationBefore = await readPageHostGeneration(client, opened.localPageId) + expect(generationBefore).not.toBeNull() + + proxy.cut(mode) + await new Promise((resolve) => setTimeout(resolve, OUTAGE_MS[mode])) + proxy.restore() + + // Why the generation: only a newly minted placement proves the page was hosted again, rather + // than read before the voided lease's guest was torn down. + try { + await expect + .poll(() => readPageHostGeneration(client!, opened.localPageId), { + timeout: 90_000, + message: 'the same desktop never hosted its page again after the network returned' + }) + .not.toBe(generationBefore) + } catch (error) { + const evidence = { + generationBefore, + generationAfter: await readPageHostGeneration(client, opened.localPageId), + unavailableNotices: await client.page + .getByText('Client-hosted browser unavailable') + .count(), + liveGuestMarker: await readClientWebviewMarker(client.page, { + urlPrefix: fixture.markerUrl, + remotePageId: opened.remotePageId + }), + noticesAfterRemount: await remountBrowserTab(client.page, worktreeId, opened.localPageId), + clientRowsAfter: await readClientBrowserRows(client.page, worktreeId), + markerAfterNavigating: await navigateGuest( + client.page, + fixture.markerUrl, + fixture.movedUrl + ) + .then(() => client!.page.waitForTimeout(10_000)) + .then(() => + readClientWebviewMarker(client!.page, { + urlPrefix: fixture.origin, + remotePageId: opened.remotePageId + }) + ) + .catch((navigationError: unknown) => `navigation failed: ${String(navigationError)}`), + proxyEvents: proxy.events + } + console.log('[outage] evidence', JSON.stringify(evidence)) + testInfo.annotations.push({ + type: 'outage-evidence', + description: JSON.stringify(evidence) + }) + await client.page.screenshot({ path: testInfo.outputPath('after-outage.png') }) + throw error + } + expect( + await waitForRenderedClientWebview( + client.page, + { urlPrefix: fixture.markerUrl, remotePageId: opened.remotePageId }, + 'the re-hosted page never rendered' + ) + ).toBe('outage-survivor') + await expect(client.page.getByText('Client-hosted browser unavailable')).toHaveCount(0) + expect( + (await findMirroredBrowserPage(client.page, worktreeId, fixture.markerUrl))?.placementKind + ).toBe('client') + expect(host.app.process().pid, 'the host must not have restarted').toBe(hostPid) + } finally { + if (client) { + await cleanupE2EDaemons(client.userDataDir).catch(() => undefined) + await client.dispose() + } + await proxy?.close() + await host.dispose() + await fixture.close() + } + }) +} From 0ae49e102872d3b1ca062833a739db619ba9bc99 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 01:10:46 -0400 Subject: [PATCH 02/12] feat(remote-browser): let a host take back pages a desktop kept through a fenced lease When a paired desktop is unreachable longer than the browser-host lease grace, the host fences its lease but keeps the page records. Until now a desktop coming back under a new lease had no way to hand the still-live pages back: the host saw records that matched the reported inventory, treated them as healthy, and left them placed nowhere. Commands then failed with browser_client_page_placement_required and the page could not load anything. A desktop can now ask for this with a new optional attach field, returningHostReclaimProtocolVersion. A host that echoes it, on attach: - rekeys each kept guest whose record still names the fenced placement (reclaimPage), so the page keeps its DOM and moves to the new lease; - closes a kept guest whose page it released meanwhile (closePage), so its answer decides what is still this desktop's; - restores at the last URL any kept guest the desktop could not vouch for. Reclaim intents take generations above what the runtime already issued. The reclaim command and planner now accept an earlier lease of the same epoch as "older", which is only ever sent to desktops that negotiated it. Older desktops never send the field, so nothing changes for them. --- .../browser-host-client-page-adoption.ts | 10 +- .../runtime/browser-host-lease-registry.ts | 25 +- .../runtime/browser-host-page-placement.ts | 4 + ...-host-page-reconciliation-orchestration.ts | 6 +- .../browser-host-page-reconciliation-plan.ts | 4 +- .../browser-host-returning-client-pages.ts | 103 +++++++++ .../rpc/methods/browser-client-host.ts | 9 +- .../runtime-browser-client-page-adoption.ts | 76 +++++- ...wser-client-page-restored-recovery.test.ts | 3 +- ...browser-client-page-returning-host.test.ts | 217 ++++++++++++++++++ src/shared/browser-client-host-protocol.ts | 39 ++-- ...lient-host-reconciliation-protocol.test.ts | 21 +- 12 files changed, 478 insertions(+), 39 deletions(-) create mode 100644 src/main/runtime/browser-host-returning-client-pages.ts create mode 100644 src/main/runtime/runtime-browser-client-page-returning-host.test.ts diff --git a/src/main/runtime/browser-host-client-page-adoption.ts b/src/main/runtime/browser-host-client-page-adoption.ts index 8a5c13868403..a05803fc49b3 100644 --- a/src/main/runtime/browser-host-client-page-adoption.ts +++ b/src/main/runtime/browser-host-client-page-adoption.ts @@ -117,7 +117,8 @@ export type BrowserHostClientPageAdoptionDependencies = { adopt( state: BrowserHostLeaseState, intents: readonly BrowserHostRuntimePageIntent[], - options: ReconciliationOptions + options: ReconciliationOptions, + releasedPageIds: readonly string[] ): Promise } placements: { getPlacement(browserPageId: string): RuntimeBrowserPlacement | undefined } @@ -135,9 +136,10 @@ export type BrowserHostClientPageAdoptionDependencies = { export async function adoptBrowserHostClientPages( intents: readonly BrowserHostRuntimePageIntent[], options: ReconciliationOptions, - dependencies: BrowserHostClientPageAdoptionDependencies + dependencies: BrowserHostClientPageAdoptionDependencies, + releasedPageIds: readonly string[] = [] ): Promise { - if (intents.length === 0) { + if (intents.length === 0 && releasedPageIds.length === 0) { return [] } const grants = intents.map((intent) => ({ @@ -145,7 +147,7 @@ export async function adoptBrowserHostClientPages( grant: dependencies.state.executionHostGrants.retain(intent.executionHostKey) })) await dependencies.reconciliations - .adopt(dependencies.state, intents, options) + .adopt(dependencies.state, intents, options, releasedPageIds) .catch(() => undefined) const adopted: string[] = [] for (const { intent, grant } of grants) { diff --git a/src/main/runtime/browser-host-lease-registry.ts b/src/main/runtime/browser-host-lease-registry.ts index 07151bf83a51..59e306734d87 100644 --- a/src/main/runtime/browser-host-lease-registry.ts +++ b/src/main/runtime/browser-host-lease-registry.ts @@ -224,14 +224,25 @@ export class BrowserHostLeaseRegistry { adoptClientPages( identity: BrowserHostLeaseIdentity, intents: readonly BrowserHostRuntimePageIntent[], - options: { maxConcurrency?: number; actionTimeoutMs?: number; signal?: AbortSignal } = {} + options: { maxConcurrency?: number; actionTimeoutMs?: number; signal?: AbortSignal } = {}, + releasedPageIds: readonly string[] = [] ): Promise { - return adoptBrowserHostClientPages(intents, options, { - state: this.requireLeaseState(identity), - reconciliations: this.pageReconciliations, - placements: this.pagePlacements, - executionHostGrants: this.clientPageExecutionHostGrants - }) + return adoptBrowserHostClientPages( + intents, + options, + { + state: this.requireLeaseState(identity), + reconciliations: this.pageReconciliations, + placements: this.pagePlacements, + executionHostGrants: this.clientPageExecutionHostGrants + }, + releasedPageIds + ) + } + + /** The lowest page generation a new placement may still take. */ + nextPageHostGeneration(): number { + return this.pagePlacements.nextPageHostGeneration } attachCommandDelivery( diff --git a/src/main/runtime/browser-host-page-placement.ts b/src/main/runtime/browser-host-page-placement.ts index f353d1311326..a20c09c9ee1c 100644 --- a/src/main/runtime/browser-host-page-placement.ts +++ b/src/main/runtime/browser-host-page-placement.ts @@ -82,6 +82,10 @@ export class BrowserHostPagePlacementRegistry { return placement } + get nextPageHostGeneration(): number { + return this.nextPageGeneration + } + reserveClientPage( browserPageId: string, host: BrowserHostPlacementIdentity, diff --git a/src/main/runtime/browser-host-page-reconciliation-orchestration.ts b/src/main/runtime/browser-host-page-reconciliation-orchestration.ts index cdb151cdfcb8..aa370ec128b3 100644 --- a/src/main/runtime/browser-host-page-reconciliation-orchestration.ts +++ b/src/main/runtime/browser-host-page-reconciliation-orchestration.ts @@ -44,9 +44,11 @@ export class BrowserHostPageReconciliationOrchestrator { adopt( state: BrowserHostLeaseState, intents: readonly BrowserHostRuntimePageIntent[], - options: BrowserHostPageReconciliationOptions = {} + options: BrowserHostPageReconciliationOptions = {}, + /** Entries naming this runtime whose pages it has since released: the one orphan it can name. */ + releasedPageIds: readonly string[] = [] ): Promise { - const claimed = new Set(intents.map((intent) => intent.browserPageId)) + const claimed = new Set([...intents.map((intent) => intent.browserPageId), ...releasedPageIds]) return this.run(state, intents, options, (inventory) => inventory.filter((page) => claimed.has(page.browserPageId)) ) diff --git a/src/main/runtime/browser-host-page-reconciliation-plan.ts b/src/main/runtime/browser-host-page-reconciliation-plan.ts index b46667e4492a..8b509784ad97 100644 --- a/src/main/runtime/browser-host-page-reconciliation-plan.ts +++ b/src/main/runtime/browser-host-page-reconciliation-plan.ts @@ -107,7 +107,9 @@ function canReclaimPage( return Boolean( intent.reclaimFrom && intent.reclaimFrom.pairedDeviceId === inventoryPairedDeviceId && - intent.authorityEpoch !== intent.reclaimFrom.authorityEpoch && + // A new epoch, or a newer lease of the same epoch for a desktop whose old lease was fenced. + (intent.authorityEpoch !== intent.reclaimFrom.authorityEpoch || + intent.browserHostGeneration > intent.reclaimFrom.browserHostGeneration) && intent.authorityRuntimeId === intent.reclaimFrom.authorityRuntimeId && sameAuthority(intent.reclaimFrom, page) && intent.browserHostClientId === intent.reclaimFrom.browserHostClientId && diff --git a/src/main/runtime/browser-host-returning-client-pages.ts b/src/main/runtime/browser-host-returning-client-pages.ts new file mode 100644 index 000000000000..567481aab52b --- /dev/null +++ b/src/main/runtime/browser-host-returning-client-pages.ts @@ -0,0 +1,103 @@ +import type { BrowserClientHostedPageInventory } from '../../shared/browser-client-host-protocol' +import { sameRuntimeBrowserPlacement } from '../../shared/runtime-browser-placement' +import type { RuntimeBrowserPlacement } from './browser-host-page-placement' +import type { BrowserHostRuntimePageIntent } from './browser-host-page-reconciliation-plan' +import type { RuntimeBrowserClientPage } from './runtime-browser-page-registry' + +export type ReturningClientPageSelection = { + /** Kept guests whose record this runtime still holds under the fenced placement. */ + reclaimable: readonly { + page: RuntimeBrowserClientPage + inventory: BrowserClientHostedPageInventory + }[] + /** Kept guests this runtime placed and has since released: closed while the desktop was away. */ + releasedPageIds: readonly string[] +} + +/** + * Sorts what a desktop that comes back after its lease was fenced still holds from THIS runtime. + * + * Only entries naming this process and epoch, this desktop, and an older lease qualify: anything + * else is a restart (adoption's case) or not this desktop's. A page some lease still places is left + * alone, since its owner, not this attach, speaks for it. + */ +export function selectReturningClientPages(input: { + inventory: readonly BrowserClientHostedPageInventory[] + authority: { authorityRuntimeId: string; authorityEpoch: string } + lease: { browserHostClientId: string; browserHostGeneration: number } + getPage(browserPageId: string): RuntimeBrowserClientPage | undefined + getPlacement(browserPageId: string): RuntimeBrowserPlacement | undefined +}): ReturningClientPageSelection { + const reclaimable: ReturningClientPageSelection['reclaimable'][number][] = [] + const releasedPageIds: string[] = [] + for (const inventory of input.inventory) { + if ( + inventory.authorityRuntimeId !== input.authority.authorityRuntimeId || + inventory.authorityEpoch !== input.authority.authorityEpoch || + inventory.browserHostClientId !== input.lease.browserHostClientId || + inventory.browserHostGeneration >= input.lease.browserHostGeneration || + input.getPlacement(inventory.browserPageId) + ) { + continue + } + const page = input.getPage(inventory.browserPageId) + if (!page) { + releasedPageIds.push(inventory.browserPageId) + } else if (sameRuntimeBrowserPlacement(page.placement, inventoryPlacement(inventory))) { + reclaimable.push({ page, inventory }) + } + } + return { reclaimable, releasedPageIds } +} + +/** + * Intents that take each kept guest back under the new lease. `reclaimFrom` lets the plan rekey an + * active guest in place; anything else it closes and restores at its last URL. + * + * Generations start above both the inventory and what this runtime has already issued, because + * the placement registry refuses one below its high-water mark. + */ +export function buildReturningClientPageIntents(input: { + pages: ReturningClientPageSelection['reclaimable'] + authority: { authorityRuntimeId: string; authorityEpoch: string } + lease: { browserHostClientId: string; browserHostGeneration: number; pairedDeviceId: string } + minimumPageHostGeneration: number +}): readonly BrowserHostRuntimePageIntent[] { + const ordered = [...input.pages].sort( + (left, right) => left.inventory.pageHostGeneration - right.inventory.pageHostGeneration + ) + const baseGeneration = ordered.reduce( + (highest, { inventory }) => Math.max(highest, inventory.pageHostGeneration), + input.minimumPageHostGeneration - 1 + ) + return ordered.map(({ page, inventory }, index) => + Object.freeze({ + authorityRuntimeId: input.authority.authorityRuntimeId, + authorityEpoch: input.authority.authorityEpoch, + browserHostClientId: input.lease.browserHostClientId, + browserHostGeneration: input.lease.browserHostGeneration, + pageHostGeneration: baseGeneration + index + 1, + browserPageId: page.browserPageId, + browserProfileId: page.browserProfileId, + executionHostKey: page.executionHostKey, + workspaceId: page.workspaceId, + reclaimFrom: Object.freeze({ + authorityRuntimeId: inventory.authorityRuntimeId, + authorityEpoch: inventory.authorityEpoch, + browserHostClientId: inventory.browserHostClientId, + browserHostGeneration: inventory.browserHostGeneration, + pageHostGeneration: inventory.pageHostGeneration, + pairedDeviceId: input.lease.pairedDeviceId + }) + }) + ) +} + +function inventoryPlacement(inventory: BrowserClientHostedPageInventory): RuntimeBrowserPlacement { + return { + kind: 'client', + browserHostClientId: inventory.browserHostClientId, + browserHostGeneration: inventory.browserHostGeneration, + pageHostGeneration: inventory.pageHostGeneration + } +} diff --git a/src/main/runtime/rpc/methods/browser-client-host.ts b/src/main/runtime/rpc/methods/browser-client-host.ts index e06632f79592..07a4617e3ebd 100644 --- a/src/main/runtime/rpc/methods/browser-client-host.ts +++ b/src/main/runtime/rpc/methods/browser-client-host.ts @@ -39,6 +39,11 @@ export const BROWSER_CLIENT_HOST_METHODS = [ } const registry = getBrowserHostLeaseRegistry(runtime) + const returningHostReclaim = + params.returningHostReclaimProtocolVersion === 1 && + params.pageReconciliationProtocolVersion === 1 && + // Lets e2e stand in for a runtime that predates the negotiation. + process.env.ORCA_E2E_DISABLE_RETURNING_HOST_RECLAIM !== '1' // Attach inventory cannot describe pages created or replaced after readiness is published. const pagePlacementsAtAttach = new Map( getRuntimeBrowserPageRegistry(runtime) @@ -98,7 +103,8 @@ export const BROWSER_CLIENT_HOST_METHODS = [ : {}), ...(handle.lease.fileChannelProtocolVersion ? { fileChannelProtocolVersion: handle.lease.fileChannelProtocolVersion } - : {}) + : {}), + ...(returningHostReclaim ? { returningHostReclaimProtocolVersion: 1 as const } : {}) }) if (params.pageCommandProtocolVersion) { releaseCommandDelivery = registry.attachCommandDelivery( @@ -120,6 +126,7 @@ export const BROWSER_CLIENT_HOST_METHODS = [ notifyWorkspace: (workspaceId) => runtime.notifyMobileSessionTabsChanged(workspaceId), resolveExecutionHostKey: (workspaceId) => runtime.resolveBrowserExecutionHostKeyForWorkspace(workspaceId), + returningHostReclaim, ...(signal ? { signal } : {}) }) if (adoption.unadoptedPageIds.length === 0) { diff --git a/src/main/runtime/runtime-browser-client-page-adoption.ts b/src/main/runtime/runtime-browser-client-page-adoption.ts index 3c84dbd9b695..7d722e540408 100644 --- a/src/main/runtime/runtime-browser-client-page-adoption.ts +++ b/src/main/runtime/runtime-browser-client-page-adoption.ts @@ -6,12 +6,20 @@ import { selectAdoptableClientHostedPages, type AdoptableClientHostedPage } from './browser-host-client-page-adoption' +import { + buildReturningClientPageIntents, + selectReturningClientPages +} from './browser-host-returning-client-pages' import { isRestoredClientHostedBrowserPlacement } from './client-hosted-browser-page-persistence' import type { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' type AdoptionAuthority = Pick< BrowserHostLeaseRegistry, - 'authorityRuntimeId' | 'authorityEpoch' | 'adoptClientPages' | 'getPlacement' + | 'authorityRuntimeId' + | 'authorityEpoch' + | 'adoptClientPages' + | 'getPlacement' + | 'nextPageHostGeneration' > /** @@ -33,6 +41,8 @@ export type RuntimeBrowserClientPageAdoptionOptions = { notifyWorkspace: (workspaceId: string) => void /** The execution-host key that workspace's pages would be created under now. */ resolveExecutionHostKey: (workspaceId: string) => Promise + /** The desktop negotiated taking back guests it kept through a fenced lease of this runtime. */ + returningHostReclaim?: boolean signal?: AbortSignal } @@ -84,7 +94,20 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( return page !== undefined && !isRestoredClientHostedBrowserPlacement(page.placement) } }) - if (adoptable.length === 0) { + const returning = options.returningHostReclaim + ? selectReturningClientPages({ + inventory, + authority: options.authority, + lease: options.lease, + getPage: (browserPageId) => options.pages.getPage(browserPageId), + getPlacement: (browserPageId) => options.authority.getPlacement(browserPageId) + }) + : { reclaimable: [], releasedPageIds: [] } + if ( + adoptable.length === 0 && + returning.reclaimable.length === 0 && + returning.releasedPageIds.length === 0 + ) { return NOTHING_TO_ADOPT } const executionHostKeyByWorkspaceId = new Map() @@ -115,7 +138,16 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( }, executionHostKeyByWorkspaceId }) - if (intents.length === 0) { + const returningIntents = buildReturningClientPageIntents({ + pages: returning.reclaimable, + authority: options.authority, + lease: options.lease, + minimumPageHostGeneration: Math.max( + options.authority.nextPageHostGeneration(), + ...intents.map((intent) => intent.pageHostGeneration + 1) + ) + }) + if (intents.length === 0 && returningIntents.length === 0 && !returning.releasedPageIds.length) { return { adoptedPageIds: [], unadoptedPageIds: settle(adoptable) } } const intentsByPageId = new Map(intents.map((intent) => [intent.browserPageId, intent])) @@ -127,10 +159,12 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( browserHostGeneration: options.lease.browserHostGeneration, pairedDeviceId: options.lease.pairedDeviceId }, - intents, - options.signal ? { signal: options.signal } : {} + [...intents, ...returningIntents], + options.signal ? { signal: options.signal } : {}, + returning.releasedPageIds ) ) + const reclaimedPageIds = republishReturningClientPages(options, returning, adoptedPageIds) const byPageId = new Map(adoptable.map((page) => [page.browserPageId, page])) const publishedWorkspaces = new Set() const publishedPageIds: string[] = [] @@ -175,11 +209,41 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( } const published = new Set(publishedPageIds) return { - adoptedPageIds: publishedPageIds, + adoptedPageIds: [...publishedPageIds, ...reclaimedPageIds], unadoptedPageIds: settle(adoptable.filter((page) => !published.has(page.browserPageId))) } } +/** Moves each kept page's record onto the placement the reclaim just committed. */ +function republishReturningClientPages( + options: RuntimeBrowserClientPageAdoptionOptions, + returning: ReturnType, + adoptedPageIds: ReadonlySet +): readonly string[] { + const reclaimed: string[] = [] + const workspaces = new Set() + for (const { page } of returning.reclaimable) { + const placement = options.authority.getPlacement(page.browserPageId) + if (!adoptedPageIds.has(page.browserPageId) || placement?.kind !== 'client') { + continue + } + try { + options.pages.replaceClientPagePlacement(page.browserPageId, page.placement, placement) + reclaimed.push(page.browserPageId) + workspaces.add(page.workspaceId) + } catch (error) { + console.warn('[browser-host-lease] returning client page republish failed:', { + browserPageId: page.browserPageId, + error + }) + } + } + for (const workspaceId of workspaces) { + options.notifyWorkspace(workspaceId) + } + return reclaimed +} + function adoptedPageUrl(page: BrowserClientHostedPageInventory): string { return page.currentUrl ?? 'about:blank' } diff --git a/src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts b/src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts index e5ba9d8efd8b..8c0be74b967d 100644 --- a/src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts +++ b/src/main/runtime/runtime-browser-client-page-restored-recovery.test.ts @@ -147,7 +147,8 @@ describe('recovery of rehydrated client-hosted pages', () => { authorityRuntimeId: 'runtime-new', authorityEpoch: 'epoch-new', getPlacement: vi.fn(() => freshPlacement), - adoptClientPages: vi.fn(async () => ['page-a']) + adoptClientPages: vi.fn(async () => ['page-a']), + nextPageHostGeneration: () => 1 } const result = await adoptRuntimeBrowserClientPagesFromInventory({ diff --git a/src/main/runtime/runtime-browser-client-page-returning-host.test.ts b/src/main/runtime/runtime-browser-client-page-returning-host.test.ts new file mode 100644 index 000000000000..e907614c88eb --- /dev/null +++ b/src/main/runtime/runtime-browser-client-page-returning-host.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + BrowserClientHostedPageInventory, + BrowserClientHostCommandEvent +} from '../../shared/browser-client-host-protocol' +import type { RuntimeBrowserClientPlacement } from '../../shared/runtime-browser-placement' +import { BrowserHostLeaseRegistry } from './browser-host-lease-registry' +import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import { adoptRuntimeBrowserClientPagesFromInventory } from './runtime-browser-client-page-adoption' + +const RUNTIME_ID = 'runtime-a' +const EPOCH = 'epoch-a' +const HOST_CLIENT_ID = 'host-a' +const WORKSPACE_ID = 'workspace-a' +const EXECUTION_HOST_KEY = 'native:runtime-a:1' + +function leaseInput(connectionId: string, pageInventory?: BrowserClientHostedPageInventory[]) { + return { + browserHostClientId: HOST_CLIENT_ID, + connectionId, + pairedDeviceId: 'device-a', + hostCapabilities: ['webview'], + pageCommandProtocolVersion: 1 as const, + pageInventoryProtocolVersion: 1 as const, + pageInventory: pageInventory ?? [], + pageReconciliationProtocolVersion: 1 as const, + leaseReconnectProtocolVersion: 1 as const + } +} + +function keptInventory( + placement: RuntimeBrowserClientPlacement, + overrides: Partial = {} +): BrowserClientHostedPageInventory { + return { + authorityRuntimeId: RUNTIME_ID, + authorityEpoch: EPOCH, + browserHostClientId: placement.browserHostClientId, + browserHostGeneration: placement.browserHostGeneration, + browserPageId: 'page-a', + pageHostGeneration: placement.pageHostGeneration, + browserProfileId: 'default', + executionHostKey: EXECUTION_HOST_KEY, + state: 'active', + currentUrl: 'https://remote.internal/kept', + workspaceId: WORKSPACE_ID, + ...overrides + } +} + +/** + * A desktop placed page-a under its first lease, then the runtime fenced that lease while the two + * could not reach each other. The runtime kept the page record; the desktop kept the guest. + */ +function fencedDesktop() { + const leases = new BrowserHostLeaseRegistry({ + authorityRuntimeId: RUNTIME_ID, + authorityEpoch: EPOCH + }) + const pages = new RuntimeBrowserPageRegistry() + const first = leases.attach(leaseInput('connection-1')) + const placement = leases.placeClientPage('page-a', HOST_CLIENT_ID) + if (placement.kind !== 'client') { + throw new Error('expected a client placement') + } + pages.publishClientPage({ + browserPageId: 'page-a', + workspaceId: WORKSPACE_ID, + browserProfileId: 'default', + executionHostKey: EXECUTION_HOST_KEY, + placement, + url: 'https://remote.internal/kept', + loading: false, + active: true + }) + first.release() + expect(leases.getPlacement('page-a')).toBeUndefined() + return { leases, pages, placement } +} + +function returnDesktop( + rig: ReturnType, + pageInventory: BrowserClientHostedPageInventory[], + returningHostReclaim = true +) { + const host = rig.leases.attach(leaseInput('connection-2', pageInventory)) + const identity = { + authorityEpoch: host.lease.authorityEpoch, + browserHostClientId: host.lease.browserHostClientId, + browserHostGeneration: host.lease.browserHostGeneration, + pairedDeviceId: host.lease.pairedDeviceId + } + const commands: BrowserClientHostCommandEvent[] = [] + rig.leases.attachCommandDelivery(identity, (event) => { + commands.push(event) + queueMicrotask(() => { + rig.leases.settleClientPageCommand( + { ...identity, connectionId: 'connection-2' }, + { ...event, result: { status: 'completed' } } + ) + }) + }) + const notifyWorkspace = vi.fn() + return { + host, + commands, + notifyWorkspace, + adopt: () => + adoptRuntimeBrowserClientPagesFromInventory({ + lease: host.lease, + authority: rig.leases, + pages: rig.pages, + notifyWorkspace, + resolveExecutionHostKey: async () => ({ + status: 'resolved', + executionHostKey: EXECUTION_HOST_KEY + }), + returningHostReclaim + }) + } +} + +describe('a desktop returning after its lease was fenced', () => { + it('gets its kept guest rekeyed onto the new lease instead of recreated', async () => { + const rig = fencedDesktop() + const returning = returnDesktop(rig, [keptInventory(rig.placement)]) + + const result = await returning.adopt() + + expect(result.adoptedPageIds).toEqual(['page-a']) + expect(returning.commands.map((event) => event.command.type)).toEqual(['reclaimPage']) + expect(returning.commands[0]).toMatchObject({ + browserHostGeneration: returning.host.lease.browserHostGeneration, + command: { + previousAuthority: { + authorityEpoch: EPOCH, + browserHostGeneration: rig.placement.browserHostGeneration, + pageHostGeneration: rig.placement.pageHostGeneration + } + } + }) + const placed = rig.leases.getPlacement('page-a') + expect(placed).toMatchObject({ + kind: 'client', + browserHostGeneration: returning.host.lease.browserHostGeneration + }) + // The record follows the new placement; the tab, its URL and its workspace stay as they were. + expect(rig.pages.getPage('page-a')).toMatchObject({ + placement: placed, + url: 'https://remote.internal/kept', + workspaceId: WORKSPACE_ID + }) + expect(returning.notifyWorkspace).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + it('closes a kept guest whose page was closed here while the desktop was away', async () => { + const rig = fencedDesktop() + rig.pages.retirePage('page-a', rig.placement) + const returning = returnDesktop(rig, [keptInventory(rig.placement)]) + + await returning.adopt() + + expect(returning.commands.map((event) => event.command.type)).toEqual(['closePage']) + expect(rig.leases.getPlacement('page-a')).toBeUndefined() + }) + + it('reloads a kept guest the desktop could not vouch for', async () => { + const rig = fencedDesktop() + const returning = returnDesktop(rig, [ + keptInventory(rig.placement, { state: 'outcomeUnknown' }) + ]) + + const result = await returning.adopt() + + expect(returning.commands.map((event) => event.command.type)).toEqual([ + 'closePage', + 'restorePage' + ]) + expect(result.adoptedPageIds).toEqual(['page-a']) + }) + + it('places reclaimed pages above every generation this runtime already issued', async () => { + const rig = fencedDesktop() + rig.leases.placeServerPage('page-later') + const returning = returnDesktop(rig, [keptInventory(rig.placement)]) + const nextGeneration = rig.leases.nextPageHostGeneration() + + await returning.adopt() + + expect(rig.leases.getPlacement('page-a')).toMatchObject({ + pageHostGeneration: nextGeneration + }) + }) + + it('leaves everything alone for a desktop that did not negotiate it', async () => { + const rig = fencedDesktop() + const returning = returnDesktop(rig, [keptInventory(rig.placement)], false) + + const result = await returning.adopt() + + expect(result.adoptedPageIds).toEqual([]) + expect(returning.commands).toEqual([]) + expect(rig.pages.getPage('page-a')?.placement).toEqual(rig.placement) + }) + + it('never speaks for a kept guest that names another runtime or a newer lease', async () => { + const rig = fencedDesktop() + const returning = returnDesktop(rig, [ + keptInventory(rig.placement, { browserPageId: 'other-epoch', authorityEpoch: 'epoch-b' }), + keptInventory(rig.placement, { browserPageId: 'current-lease', browserHostGeneration: 99 }) + ]) + + await returning.adopt() + + expect(returning.commands).toEqual([]) + }) +}) diff --git a/src/shared/browser-client-host-protocol.ts b/src/shared/browser-client-host-protocol.ts index 934b468a3d29..e6427326d2bd 100644 --- a/src/shared/browser-client-host-protocol.ts +++ b/src/shared/browser-client-host-protocol.ts @@ -32,6 +32,19 @@ const PageReconciliationProtocolVersion = z.literal( BROWSER_CLIENT_HOST_PAGE_RECONCILIATION_PROTOCOL_VERSION ) const FileChannelProtocolVersion = z.literal(BROWSER_CLIENT_FILE_CHANNEL_PROTOCOL_VERSION) +// A desktop whose lease was fenced while unreachable re-attaches with the guests it kept; a runtime +// that echoes this rekeys them in place instead of reloading them. +const ReturningHostReclaimProtocolVersion = z.literal(1) + +/** Every optional protocol a ready event can confirm, and so a lease authority can carry. */ +const NegotiatedBrowserHostProtocols = { + pageCommandProtocolVersion: PageCommandProtocolVersion.optional(), + pageInventoryProtocolVersion: PageInventoryProtocolVersion.optional(), + leaseReconnectProtocolVersion: LeaseReconnectProtocolVersion.optional(), + pageReconciliationProtocolVersion: PageReconciliationProtocolVersion.optional(), + fileChannelProtocolVersion: FileChannelProtocolVersion.optional(), + returningHostReclaimProtocolVersion: ReturningHostReclaimProtocolVersion.optional() +} /** * Sent when an attach names a runtime id this process does not have. It means "a newer authority @@ -117,7 +130,8 @@ export const BrowserClientHostAttachParams = z pageInventory: BrowserClientHostedPageInventoryList.optional(), leaseReconnectProtocolVersion: LeaseReconnectProtocolVersion.optional(), pageReconciliationProtocolVersion: PageReconciliationProtocolVersion.optional(), - fileChannelProtocolVersion: FileChannelProtocolVersion.optional() + fileChannelProtocolVersion: FileChannelProtocolVersion.optional(), + returningHostReclaimProtocolVersion: ReturningHostReclaimProtocolVersion.optional() }) .superRefine((params, context) => { if ( @@ -171,11 +185,7 @@ export const BrowserClientHostReady = z.object({ type: z.literal('ready'), authorityEpoch: Identity, browserHostGeneration: Generation, - pageCommandProtocolVersion: PageCommandProtocolVersion.optional(), - pageInventoryProtocolVersion: PageInventoryProtocolVersion.optional(), - leaseReconnectProtocolVersion: LeaseReconnectProtocolVersion.optional(), - pageReconciliationProtocolVersion: PageReconciliationProtocolVersion.optional(), - fileChannelProtocolVersion: FileChannelProtocolVersion.optional() + ...NegotiatedBrowserHostProtocols }) const BrowserClientHostRevoked = z.object({ @@ -185,13 +195,9 @@ const BrowserClientHostRevoked = z.object({ reason: z.enum(['replaced', 'released']) }) -export const BrowserClientHostLeaseAuthority = BrowserHostLeaseAuthority.extend({ - pageCommandProtocolVersion: PageCommandProtocolVersion.optional(), - pageInventoryProtocolVersion: PageInventoryProtocolVersion.optional(), - leaseReconnectProtocolVersion: LeaseReconnectProtocolVersion.optional(), - pageReconciliationProtocolVersion: PageReconciliationProtocolVersion.optional(), - fileChannelProtocolVersion: FileChannelProtocolVersion.optional() -}) +export const BrowserClientHostLeaseAuthority = BrowserHostLeaseAuthority.extend( + NegotiatedBrowserHostProtocols +) export type BrowserClientHostLeaseAuthority = z.infer @@ -304,13 +310,16 @@ export const BrowserClientHostCommandEvent = BrowserClientPageCommandAuthority.e message: 'Browser page reconciliation client authority does not match' }) } + // Older means a previous epoch, or (negotiated by returningHostReclaimProtocolVersion) an earlier + // lease of this epoch that was fenced while the desktop was unreachable. if ( event.command.type === 'reclaimPage' && - event.command.previousAuthority.authorityEpoch === event.authorityEpoch + event.command.previousAuthority.authorityEpoch === event.authorityEpoch && + event.command.previousAuthority.browserHostGeneration >= event.browserHostGeneration ) { context.addIssue({ code: 'custom', - message: 'Browser page reclaim requires an older authority epoch' + message: 'Browser page reclaim requires an older authority' }) } }) diff --git a/src/shared/browser-client-host-reconciliation-protocol.test.ts b/src/shared/browser-client-host-reconciliation-protocol.test.ts index 9ce314ee9b27..4e4801f4ed04 100644 --- a/src/shared/browser-client-host-reconciliation-protocol.test.ts +++ b/src/shared/browser-client-host-reconciliation-protocol.test.ts @@ -161,14 +161,31 @@ describe('browser client-host reconciliation protocol', () => { authorityRuntimeId: 'runtime-a', authorityEpoch: 'epoch-new', browserHostClientId: 'host-a', - browserHostGeneration: 2, + browserHostGeneration: 4, pageHostGeneration: 3 }, browserProfileId: 'profile-a', executionHostKey: 'native:runtime-a:1' }) ) - ).toThrow('Browser page reclaim requires an older authority epoch') + ).toThrow('Browser page reclaim requires an older authority') + // Same epoch, earlier lease: a desktop taking back guests it kept through a fenced lease. + expect( + BrowserClientHostCommandEvent.parse( + command({ + type: 'reclaimPage', + previousAuthority: { + authorityRuntimeId: 'runtime-a', + authorityEpoch: 'epoch-new', + browserHostClientId: 'host-a', + browserHostGeneration: 2, + pageHostGeneration: 3 + }, + browserProfileId: 'profile-a', + executionHostKey: 'native:runtime-a:1' + }) + ).command.type + ).toBe('reclaimPage') expect(() => BrowserClientHostCommandEvent.parse( command({ From f5ceada0fe86a8a03a76fc30ea05cbc14de7d99b Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 01:11:01 -0400 Subject: [PATCH 03/12] fix(remote-browser): park client-hosted pages through an outage and re-attach on real events A paired desktop that lost its host for longer than the 15s lease reconnect grace treated the expired grace as final: it closed the composition, destroyed every live page and dropped the registry record, and nothing ever registered again. The tab sat on "Client-hosted browser unavailable" for good. Losing contact is not evidence the pages are gone, so the composition now parks instead. BrowserClientHostParking is the one state machine: - the lease marks reconnect failures as lost contact; that, a replaced runtime, and a route that stayed dark all park rather than tear down; - parked keeps the executor and its guests, keeps routes suspended (page traffic fails instead of leaving from this desktop) and closes the dead lease; - a real event asks for exactly one re-attach, shared by concurrent asks: the environment's control connection becoming ready again, the existing online/wake retry IPC, and the user opening the tab; - the re-attach reuses the runtime-replacement path: fresh lease, same executor, inventory sent so the host rekeys the same guests, and each reclaimed guest moves its partition onto the new lease's route; - a host that does not echo returningHostReclaimProtocolVersion gets the guests freed first, so its existing recovery reloads them at their last URL; - after an hour parked the guests are freed to bound memory, keeping the tabs; this timer decides nothing about whether a page still exists; - unpair, re-pair and quit still close the composition. This deletes the grace-expiry teardown and the 45s authority-replacement wait, which existed only to bound how long a dead lease kept guests. --- .../browser-client-host-attach-request.ts | 7 +- ...-client-host-authority-replacement-wait.ts | 36 --- ...-client-host-authority-replacement.test.ts | 77 +----- ...owser-client-host-authority-replacement.ts | 5 +- .../browser-client-host-parking.test.ts | 170 +++++++++++++ .../browser/browser-client-host-parking.ts | 167 +++++++++++++ ...ient-page-command-executor-dependencies.ts | 2 +- ...ient-page-command-executor-test-harness.ts | 3 + .../browser-client-page-command-executor.ts | 3 +- ...er-client-page-command-integration.test.ts | 3 +- ...lient-page-reconciliation-adapters.test.ts | 52 +++- .../browser-client-page-reconciliation.ts | 51 +++- ...browser-client-page-unavailability.test.ts | 3 +- .../browser-host-lease-contact-loss.ts | 20 ++ ...route-partition-storage-retirement.test.ts | 1 + .../browser/browser-route-session-policy.ts | 34 ++- .../browser/browser-route-session-registry.ts | 13 + ...rowser-client-host-composition-contract.ts | 71 ++++++ ...me-browser-client-host-composition.test.ts | 231 ++++++++++++------ ...runtime-browser-client-host-composition.ts | 186 ++++++-------- ...ntime-browser-client-host-registry.test.ts | 57 +++++ ...ed-runtime-browser-client-host-registry.ts | 33 ++- ...red-runtime-browser-client-host-runtime.ts | 25 +- .../paired-runtime-browser-client-host.ts | 5 + ...d-runtime-browser-host-lease-connection.ts | 9 + ...ired-runtime-browser-host-lease-options.ts | 1 + ...ntime-browser-host-lease-reconnect.test.ts | 3 + .../paired-runtime-browser-host-lease.ts | 3 +- ...environment-browser-client-host-handler.ts | 17 ++ .../runtime-environment-recovery-handler.ts | 3 + ...runtime-environment-request-connections.ts | 7 + ...untime-environment-shared-control-ready.ts | 19 ++ .../ipc/runtime-environments-pairing.test.ts | 1 + src/preload/api/runtime-api.ts | 2 + .../api/runtime-environments-bridge.ts | 2 + 35 files changed, 1013 insertions(+), 309 deletions(-) delete mode 100644 src/main/browser/browser-client-host-authority-replacement-wait.ts create mode 100644 src/main/browser/browser-client-host-parking.test.ts create mode 100644 src/main/browser/browser-client-host-parking.ts create mode 100644 src/main/browser/browser-host-lease-contact-loss.ts create mode 100644 src/main/browser/paired-runtime-browser-client-host-composition-contract.ts create mode 100644 src/main/ipc/runtime-environment-shared-control-ready.ts diff --git a/src/main/browser/browser-client-host-attach-request.ts b/src/main/browser/browser-client-host-attach-request.ts index c1e78841599c..05f5adc46502 100644 --- a/src/main/browser/browser-client-host-attach-request.ts +++ b/src/main/browser/browser-client-host-attach-request.ts @@ -50,6 +50,9 @@ export function createBrowserClientHostAttachRequest( const fileChannelProtocolVersion = pageCommandProtocolVersion ? options.fileChannelProtocolVersion : undefined + const returningHostReclaimProtocolVersion = pageReconciliationProtocolVersion + ? options.returningHostReclaimProtocolVersion + : undefined const params = BrowserClientHostAttachParams.parse({ authorityRuntimeId: options.authorityRuntimeId, browserHostClientId: options.browserHostClientId, @@ -63,7 +66,8 @@ export function createBrowserClientHostAttachRequest( : {}), ...(leaseReconnectProtocolVersion ? { leaseReconnectProtocolVersion } : {}), ...(pageReconciliationProtocolVersion ? { pageReconciliationProtocolVersion } : {}), - ...(fileChannelProtocolVersion ? { fileChannelProtocolVersion } : {}) + ...(fileChannelProtocolVersion ? { fileChannelProtocolVersion } : {}), + ...(returningHostReclaimProtocolVersion ? { returningHostReclaimProtocolVersion } : {}) }) return { pageCommandProtocolVersion, @@ -71,6 +75,7 @@ export function createBrowserClientHostAttachRequest( leaseReconnectProtocolVersion, pageReconciliationProtocolVersion, fileChannelProtocolVersion, + returningHostReclaimProtocolVersion, params } } diff --git a/src/main/browser/browser-client-host-authority-replacement-wait.ts b/src/main/browser/browser-client-host-authority-replacement-wait.ts deleted file mode 100644 index 38a1dcd1c5d4..000000000000 --- a/src/main/browser/browser-client-host-authority-replacement-wait.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const DEFAULT_AUTHORITY_REPLACEMENT_GRACE_MS = 45_000 - -/** - * Holds a composition open while a replaced runtime's successor comes back to reclaim its guests. - * - * The bound is the point: a runtime that never returns would otherwise leave live webviews attached - * to an authority that will never speak again, which is worse than retiring the environment. Arming - * is idempotent, so a burst of mismatch errors still yields one deadline. - */ -export class BrowserClientHostAuthorityReplacementWait { - private timer: ReturnType | null = null - - constructor(private readonly graceMs: number = DEFAULT_AUTHORITY_REPLACEMENT_GRACE_MS) {} - - arm(expire: () => void): void { - if (this.timer) { - return - } - this.timer = setTimeout(() => { - this.timer = null - expire() - }, this.graceMs) - this.timer.unref?.() - } - - cancel(): void { - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - } - - get armed(): boolean { - return this.timer !== null - } -} diff --git a/src/main/browser/browser-client-host-authority-replacement.test.ts b/src/main/browser/browser-client-host-authority-replacement.test.ts index 8988fdb750bd..81a72274e563 100644 --- a/src/main/browser/browser-client-host-authority-replacement.test.ts +++ b/src/main/browser/browser-client-host-authority-replacement.test.ts @@ -1,7 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browser-client-host-protocol' import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' -import { BrowserClientHostAuthorityReplacementWait } from './browser-client-host-authority-replacement-wait' function errorWithCode(message: string, code: unknown): Error { return Object.assign(new Error(message), { code }) @@ -72,77 +71,3 @@ describe('browser client host authority replacement', () => { ) }) }) - -describe('BrowserClientHostAuthorityReplacementWait', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.useRealTimers() - }) - - it('expires exactly once, at the grace deadline', () => { - const wait = new BrowserClientHostAuthorityReplacementWait(1_000) - const expire = vi.fn() - - wait.arm(expire) - - expect(wait.armed).toBe(true) - vi.advanceTimersByTime(999) - expect(expire).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - expect(expire).toHaveBeenCalledOnce() - expect(wait.armed).toBe(false) - vi.advanceTimersByTime(10_000) - expect(expire).toHaveBeenCalledOnce() - }) - - // A replaced runtime typically produces a burst of mismatch errors, one per in-flight attach. - // Re-arming per error would push the deadline out indefinitely, which is the unbounded hold the - // class exists to prevent. - it('keeps the first deadline when armed again while already armed', () => { - const wait = new BrowserClientHostAuthorityReplacementWait(1_000) - const first = vi.fn() - const second = vi.fn() - - wait.arm(first) - vi.advanceTimersByTime(900) - wait.arm(second) - vi.advanceTimersByTime(100) - - expect(first).toHaveBeenCalledOnce() - expect(second).not.toHaveBeenCalled() - }) - - it('never expires after a cancel', () => { - const wait = new BrowserClientHostAuthorityReplacementWait(1_000) - const expire = vi.fn() - wait.arm(expire) - - wait.cancel() - - expect(wait.armed).toBe(false) - vi.advanceTimersByTime(10_000) - expect(expire).not.toHaveBeenCalled() - }) - - it('is re-armable after a cancel and after an expiry', () => { - const wait = new BrowserClientHostAuthorityReplacementWait(1_000) - const cancelled = vi.fn() - const rearmed = vi.fn() - wait.arm(cancelled) - wait.cancel() - - wait.arm(rearmed) - vi.advanceTimersByTime(1_000) - - expect(cancelled).not.toHaveBeenCalled() - expect(rearmed).toHaveBeenCalledOnce() - - const afterExpiry = vi.fn() - wait.arm(afterExpiry) - vi.advanceTimersByTime(1_000) - expect(afterExpiry).toHaveBeenCalledOnce() - }) -}) diff --git a/src/main/browser/browser-client-host-authority-replacement.ts b/src/main/browser/browser-client-host-authority-replacement.ts index 77b7382cb40b..f926707a3cec 100644 --- a/src/main/browser/browser-client-host-authority-replacement.ts +++ b/src/main/browser/browser-client-host-authority-replacement.ts @@ -3,9 +3,8 @@ import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browse /** * True when a lease failed because the runtime it named has been replaced by a newer one. * - * This is the restart signal, and it is the one host error that must not retire the environment: - * the webview guests are still alive and still ours, and the replacement authority arrives moments - * later to reclaim them. Every other host error means the host itself is unusable. + * This is the restart signal: the webview guests are still alive and still ours, and the + * replacement authority arrives later to reclaim them, so the composition parks for it. */ export function isBrowserClientHostAuthorityReplaced(error: unknown): boolean { if (!(error instanceof Error)) { diff --git a/src/main/browser/browser-client-host-parking.test.ts b/src/main/browser/browser-client-host-parking.test.ts new file mode 100644 index 000000000000..6d5786049443 --- /dev/null +++ b/src/main/browser/browser-client-host-parking.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' +import { BrowserClientHostParking, releaseBrowserClientGuests } from './browser-client-host-parking' + +const authority: BrowserClientHostLeaseAuthority = { + authorityRuntimeId: 'runtime-a', + authorityEpoch: 'epoch-a', + browserHostClientId: 'client-a', + browserHostGeneration: 2 +} + +function createOwner() { + const order: string[] = [] + const attaches: { resolve(): void; reject(error: Error): void }[] = [] + const owner = { + closed: false, + isClosed: () => owner.closed, + retireLease: vi.fn(async (_input: string, _error: Error, releaseGuests: boolean) => { + order.push(releaseGuests ? 'retire-lease+release-guests' : 'retire-lease') + }), + attach: vi.fn( + (input: string) => + new Promise((resolve, reject) => { + order.push(`attach:${input}`) + attaches.push({ resolve: () => resolve(authority), reject }) + }) + ), + suspend: vi.fn(() => { + order.push('suspend') + }), + releaseGuests: vi.fn(async () => { + order.push('release-guests') + }) + } + return { owner, order, attaches } +} + +async function settleAttach(rig: ReturnType, index = 0): Promise { + await vi.waitFor(() => expect(rig.attaches[index]).toBeDefined()) + rig.attaches[index]!.resolve() +} + +describe('BrowserClientHostParking', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('suspends the lease on every loss but arms one memory discard', () => { + const rig = createOwner() + const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) + + parking.park(new Error('grace expired')) + parking.park(new Error('grace expired again')) + vi.advanceTimersByTime(999) + expect(rig.owner.releaseGuests).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + + expect(rig.order).toEqual(['suspend', 'suspend', 'release-guests']) + // Freeing memory decides nothing: the state stays parked, waiting for the host. + expect(parking.isParked).toBe(true) + }) + + it('does nothing for a closed owner and leaves no timer after dispose', () => { + const rig = createOwner() + const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) + + parking.park(new Error('lost')) + parking.dispose() + rig.owner.closed = true + parking.park(new Error('lost')) + vi.advanceTimersByTime(10_000) + + expect(parking.isParked).toBe(false) + expect(rig.owner.releaseGuests).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it('answers a trigger only while parked, and shares one attempt between triggers', async () => { + const rig = createOwner() + const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) + expect(parking.resume()).toBeNull() + parking.park(new Error('lost')) + parking.noteAuthority({ ...authority, returningHostReclaimProtocolVersion: 1 }) + + const first = parking.resume() + const second = parking.resume() + expect(second).toBe(first) + await settleAttach(rig) + + await expect(first).resolves.toEqual(authority) + expect(rig.order).toEqual(['suspend', 'retire-lease', 'attach:input-a']) + expect(parking.isParked).toBe(false) + vi.advanceTimersByTime(10_000) + expect(rig.owner.releaseGuests).not.toHaveBeenCalled() + }) + + it('frees kept guests before re-attaching to a runtime that cannot rekey them', async () => { + const rig = createOwner() + const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) + parking.noteAuthority(authority) + parking.park(new Error('lost')) + + const resumed = parking.resume() + await settleAttach(rig) + await resumed + + expect(rig.order).toEqual(['suspend', 'retire-lease+release-guests', 'attach:input-a']) + }) + + it('stays parked after a failed attempt, and the very next trigger tries again', async () => { + const rig = createOwner() + const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) + parking.park(new Error('lost')) + + const failed = parking.resume() + await vi.waitFor(() => expect(rig.attaches).toHaveLength(1)) + rig.attaches[0]!.reject(new Error('still unreachable')) + await expect(failed).rejects.toThrow('still unreachable') + expect(parking.isParked).toBe(true) + + const retried = parking.resume() + expect(retried).not.toBe(failed) + await settleAttach(rig, 1) + await expect(retried).resolves.toEqual(authority) + }) + + it('runs a replacement runtime only after the attempt in flight settles', async () => { + const rig = createOwner() + const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) + parking.park(new Error('lost')) + + const resumed = parking.resume() + const replaced = parking.replace('input-b') + await vi.waitFor(() => expect(rig.attaches).toHaveLength(1)) + expect(rig.order).not.toContain('attach:input-b') + rig.attaches[0]!.reject(new Error('authority mismatch')) + await expect(resumed).rejects.toThrow('authority mismatch') + await settleAttach(rig, 1) + + await expect(replaced).resolves.toEqual(authority) + expect(rig.order.at(-1)).toBe('attach:input-b') + }) +}) + +describe('releaseBrowserClientGuests', () => { + it('retires every guest the executor still holds and reports every failure', async () => { + const retirePage = vi.fn(async (browserPageId: string) => { + if (browserPageId === 'page-b') { + throw new Error('cleanup failed') + } + return true + }) + const executor = { + snapshotPageInventory: () => [ + { browserPageId: 'page-a', pageHostGeneration: 3 }, + { browserPageId: 'page-b', pageHostGeneration: 4 } + ], + retirePage + } + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: release reads only id and generation. + await expect(releaseBrowserClientGuests(executor as never)).rejects.toThrow(AggregateError) + + expect(retirePage).toHaveBeenCalledWith('page-a', 3) + expect(retirePage).toHaveBeenCalledWith('page-b', 4) + }) +}) diff --git a/src/main/browser/browser-client-host-parking.ts b/src/main/browser/browser-client-host-parking.ts new file mode 100644 index 000000000000..364433395255 --- /dev/null +++ b/src/main/browser/browser-client-host-parking.ts @@ -0,0 +1,167 @@ +import type { + BrowserClientHostedPageInventory, + BrowserClientHostLeaseAuthority +} from '../../shared/browser-client-host-protocol' + +/** How long a parked desktop keeps its live guests before freeing them; the tabs stay either way. */ +export const PARKED_GUEST_DISCARD_MS = 60 * 60 * 1000 + +type ParkedLeaseOwner = { + isClosed(): boolean + /** Drops the current lease and routes, keeping the executor; frees every guest when asked to. */ + retireLease(input: Start, error: Error, releaseGuests: boolean): Promise + attach(input: Start): Promise + /** Suspends routes and closes the lease, keeping every page. */ + suspend(error: Error): void + releaseGuests(): Promise +} + +/** + * The "runtime unreachable" state of a client-hosted browser composition, and the re-attach out + * of it. + * + * Losing contact is never evidence the pages are gone, so the composition parks instead of tearing + * down: guests stay, routes stay suspended (page traffic fails rather than leaving locally), and a + * real event (connection ready, wake, online, the user opening the tab) asks for one re-attach. + * Concurrent asks share that attempt. The only timer frees memory after a long absence; it decides + * nothing about whether a page still exists. + */ +export class BrowserClientHostParking { + private parked = false + private discardTimer: ReturnType | null = null + private attempt: { + key: Start + token: symbol + promise: Promise + } | null = null + /** Whether the runtime last attached to can rekey guests this desktop kept through a fence. */ + private returningHostReclaim = false + + constructor( + private input: Start, + private readonly owner: ParkedLeaseOwner, + private readonly discardAfterMs = PARKED_GUEST_DISCARD_MS + ) {} + + get isParked(): boolean { + return this.parked + } + + noteAuthority(authority: BrowserClientHostLeaseAuthority): void { + this.returningHostReclaim = authority.returningHostReclaimProtocolVersion === 1 + } + + park(error: Error): void { + if (this.owner.isClosed()) { + return + } + if (!this.parked) { + console.warn('[browser-client-host] runtime unreachable; keeping pages until it returns:', { + error: error.message + }) + this.parked = true + this.discardTimer = setTimeout(() => { + this.discardTimer = null + void this.owner.releaseGuests().catch(warnParkedCleanup) + }, this.discardAfterMs) + this.discardTimer.unref?.() + } + this.owner.suspend(error) + } + + /** A newer runtime took over: re-attach under it, keeping every live guest. */ + replace(input: Start): Promise { + this.input = input + return this.coalesce(input, false) + } + + /** One re-attach attempt, or null when not parked. */ + resume(): Promise | null { + return this.parked ? this.coalesce(this.input, true) : null + } + + dispose(): void { + this.parked = false + this.clearDiscardTimer() + } + + /** One attempt per input at a time; an attempt for a newer input waits for the current one. */ + private coalesce(input: Start, resuming: boolean): Promise { + const current = this.attempt + if (current && current.key === input) { + return current.promise + } + const settled = current ? current.promise.catch(() => undefined) : Promise.resolve() + const token = Symbol('browser-client-host-attempt') + // Cleared before callers resume, so a trigger right after a failed attempt starts a new one. + const promise = settled + .then(() => this.rehost(input, resuming)) + .finally(() => { + if (this.attempt?.token === token) { + this.attempt = null + } + }) + this.attempt = { key: input, token, promise } + return promise + } + + private async rehost(input: Start, resuming: boolean): Promise { + this.assertOpen() + // An older runtime cannot rekey a kept guest and would leave it placed nowhere; freeing it lets + // that runtime's own recovery recreate the page at its last URL instead. + const releaseGuests = resuming && !this.returningHostReclaim + await this.owner.retireLease( + input, + new Error('Browser client host is re-attaching'), + releaseGuests + ) + this.assertOpen() + try { + const authority = await this.owner.attach(input) + this.parked = false + this.clearDiscardTimer() + return authority + } catch (error) { + this.park(error instanceof Error ? error : new Error(String(error))) + throw error + } + } + + private assertOpen(): void { + if (this.owner.isClosed()) { + throw new Error('paired_runtime_browser_client_host_composition_closed') + } + } + + private clearDiscardTimer(): void { + if (this.discardTimer) { + clearTimeout(this.discardTimer) + this.discardTimer = null + } + } +} + +/** Frees every live guest; the runtime recreates each page at its last URL when it can. */ +export async function releaseBrowserClientGuests(executor: { + snapshotPageInventory(): readonly BrowserClientHostedPageInventory[] + retirePage(browserPageId: string, pageHostGeneration: number): Promise +}): Promise { + const results = await Promise.allSettled( + executor + .snapshotPageInventory() + .map((page) => executor.retirePage(page.browserPageId, page.pageHostGeneration)) + ) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser client guest release failed') + } +} + +export function warnParkedCleanup(error: unknown): void { + console.warn( + '[browser-client-host] parked cleanup failed:', + error instanceof Error ? error.message : String(error) + ) +} diff --git a/src/main/browser/browser-client-page-command-executor-dependencies.ts b/src/main/browser/browser-client-page-command-executor-dependencies.ts index 4e647bc93daa..4ab8bf022d83 100644 --- a/src/main/browser/browser-client-page-command-executor-dependencies.ts +++ b/src/main/browser/browser-client-page-command-executor-dependencies.ts @@ -32,7 +32,7 @@ export type BrowserClientPageCommandExecutorDependencies = BrowserClientPageAuth signal: AbortSignal ): Promise selectRenderer(): BrowserClientPageRenderer - routeSessions: Pick + routeSessions: Pick routeWebContents: BrowserClientPageLifecycleRegistry guestBinding: BrowserClientPageGuestBinding executeAutomation( diff --git a/src/main/browser/browser-client-page-command-executor-test-harness.ts b/src/main/browser/browser-client-page-command-executor-test-harness.ts index 56f9cd543e9d..270a7304ff42 100644 --- a/src/main/browser/browser-client-page-command-executor-test-harness.ts +++ b/src/main/browser/browser-client-page-command-executor-test-harness.ts @@ -92,6 +92,9 @@ export function createHarness(options: { maxPages?: number } = {}) { preparePage: vi.fn(async () => { order.push('prepare-page') return routeSession + }), + retargetPartitionProxy: vi.fn(async () => { + order.push('retarget-partition-proxy') }) }, executeAutomation: vi.fn(async () => ({ clicked: true })), diff --git a/src/main/browser/browser-client-page-command-executor.ts b/src/main/browser/browser-client-page-command-executor.ts index 1580c75a785f..bfad1f846478 100644 --- a/src/main/browser/browser-client-page-command-executor.ts +++ b/src/main/browser/browser-client-page-command-executor.ts @@ -107,9 +107,10 @@ export class BrowserClientPageCommandExecutor { case 'restorePage': return executeBrowserClientPageReconciliationCommand( { + // routeWebContents, routeSessions and retainNetworkRoute come straight from here. + ...this.dependencies, pages: this.pages, failedPages: this.failedPages, - routeWebContents: this.dependencies.routeWebContents, assertAvailable: () => this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning), createPage: (command, commandSignal) => this.createPage(command, commandSignal), diff --git a/src/main/browser/browser-client-page-command-integration.test.ts b/src/main/browser/browser-client-page-command-integration.test.ts index 4d416fba8687..3c849c7f25d7 100644 --- a/src/main/browser/browser-client-page-command-integration.test.ts +++ b/src/main/browser/browser-client-page-command-integration.test.ts @@ -114,7 +114,8 @@ describe('BrowserClientPageCommandExecutor integration', () => { await expect(executor.retirePage('page-a', 8)).resolves.toBe(true) expect(guest.webContents.close).not.toHaveBeenCalled() expect(rendererRetire).toHaveBeenCalledOnce() - expect(routeRelease).toHaveBeenCalledOnce() + // Once for the route the reclaim moved the page off, once for the route it retired with. + expect(routeRelease).toHaveBeenCalledTimes(2) expect(clearPolicies).toHaveBeenCalledOnce() expect(sessionRegistry.isAllowedPartition(partition)).toBe(false) }) diff --git a/src/main/browser/browser-client-page-reconciliation-adapters.test.ts b/src/main/browser/browser-client-page-reconciliation-adapters.test.ts index afed5fee0dc5..6b56e02072fc 100644 --- a/src/main/browser/browser-client-page-reconciliation-adapters.test.ts +++ b/src/main/browser/browser-client-page-reconciliation-adapters.test.ts @@ -119,7 +119,10 @@ function createHarness() { storageScope: 'a'.repeat(64), retainNetworkRoute: vi.fn(async () => route), selectRenderer: vi.fn(() => renderer), - routeSessions: { preparePage: vi.fn(async () => routeSession) }, + routeSessions: { + preparePage: vi.fn(async () => routeSession), + retargetPartitionProxy: vi.fn(async () => {}) + }, executeAutomation: vi.fn(async () => undefined), retireAutomation: vi.fn(async () => {}), guestBinding: { bind: vi.fn(), release: vi.fn() }, @@ -165,6 +168,53 @@ describe('browser client page reconciliation adapters', () => { ]) }) + it('moves a reclaimed guest onto the current lease route and lets the old one go', async () => { + const { dependencies, executor } = createHarness() + await executor.handle(command('createPage'), new AbortController().signal) + const previousRoute = await dependencies.retainNetworkRoute.mock.results[0]!.value + const currentRoute = { + ...previousRoute, + proxyEndpoint: { host: '127.0.0.1' as const, port: 43124 }, + release: vi.fn() + } + dependencies.retainNetworkRoute.mockResolvedValueOnce(currentRoute) + + await expect( + executor.handle(command('reclaimPage'), new AbortController().signal) + ).resolves.toEqual({ status: 'completed' }) + + // The old route died with the replaced lease; without the retarget the kept page never loads. + expect(dependencies.routeSessions.retargetPartitionProxy).toHaveBeenCalledWith( + partition, + currentRoute.proxyEndpoint + ) + expect(previousRoute.release).toHaveBeenCalledOnce() + expect(currentRoute.release).not.toHaveBeenCalled() + expect(executor.hasPage('page-a', 8)).toBe(true) + }) + + it('closes a reclaimed guest that cannot be moved onto the current route', async () => { + const { dependencies, executor } = createHarness() + await executor.handle(command('createPage'), new AbortController().signal) + const currentRoute = { + ...(await dependencies.retainNetworkRoute.mock.results[0]!.value), + proxyEndpoint: { host: '127.0.0.1' as const, port: 43124 }, + release: vi.fn() + } + dependencies.retainNetworkRoute.mockResolvedValueOnce(currentRoute) + dependencies.routeSessions.retargetPartitionProxy.mockRejectedValueOnce( + new Error('browser_route_partition_proxy_verification_failed') + ) + + await expect( + executor.handle(command('reclaimPage'), new AbortController().signal) + ).resolves.toEqual(expect.objectContaining({ status: 'failed' })) + + expect(currentRoute.release).toHaveBeenCalledOnce() + expect(executor.hasPage('page-a', 7)).toBe(false) + expect(executor.hasPage('page-a', 8)).toBe(false) + }) + it('rejects DOM-preserving reclaim across runtime authorities', async () => { const { executor, renderer, routeWebContents } = createHarness() await executor.handle(command('createPage'), new AbortController().signal) diff --git a/src/main/browser/browser-client-page-reconciliation.ts b/src/main/browser/browser-client-page-reconciliation.ts index f1e643ab2ae4..6e99e55a278e 100644 --- a/src/main/browser/browser-client-page-reconciliation.ts +++ b/src/main/browser/browser-client-page-reconciliation.ts @@ -17,7 +17,11 @@ import type { BrowserClientPageLifecycleRegistry, BrowserClientRetainedPage } from './browser-client-page-retained-state' -import type { BrowserClientPageRendererIdentity } from './browser-client-page-cleanup' +import type { + BrowserClientPageNetworkRoute, + BrowserClientPageRendererIdentity +} from './browser-client-page-cleanup' +import type { BrowserRouteSessionRegistry } from './browser-route-session-registry' type BrowserClientPageReconciliationContext = { pages: Map @@ -25,6 +29,11 @@ type BrowserClientPageReconciliationContext = { routeWebContents: BrowserClientPageLifecycleRegistry assertAvailable: () => void createPage: (event: BrowserClientHostCommandEvent, signal: AbortSignal) => Promise + retainNetworkRoute( + executionHostKey: string, + signal: AbortSignal + ): Promise + routeSessions: Pick navigate: (event: BrowserClientHostCommandEvent, signal: AbortSignal) => Promise retirePage: (browserPageId: string, pageHostGeneration: number) => Promise cleanupPage: ( @@ -125,6 +134,7 @@ async function reclaimPage( page.routeSession = rekeyed.routeSession page.inventory = createReconciliationInventory(event, 'outcomeUnknown', page.inventory.currentUrl) try { + await rebindReclaimedPageRoute(context, page, event.command.executionHostKey, signal) await rekeyRendererPage.call(page.renderer, previousRendererPage, nextRendererPage, signal) assertBrowserClientPageCommandNotAborted(signal) context.assertAvailable() @@ -139,6 +149,45 @@ async function reclaimPage( } } +/** + * Moves a reclaimed guest onto the current lease's route. The old route belonged to the lease being + * replaced and its tunnel is gone, so without this the kept page could render but never load. + */ +async function rebindReclaimedPageRoute( + context: BrowserClientPageReconciliationContext, + page: BrowserClientRetainedPage, + executionHostKey: string, + signal: AbortSignal +): Promise { + const route = await context.retainNetworkRoute(executionHostKey, signal) + if (route === page.route) { + return + } + try { + if ( + route.key !== executionHostKey || + route.executionHostIdentity !== page.route.executionHostIdentity + ) { + throw new BrowserClientPageCommandError('browser_client_page_execution_host_stale') + } + assertBrowserClientPageCommandNotAborted(signal) + await context.routeSessions.retargetPartitionProxy( + page.routeSession.partition, + route.proxyEndpoint + ) + } catch (error) { + await route.release() + throw error + } + const previousRoute = page.route + page.route = route + try { + await previousRoute.release() + } catch { + // The replaced lease retired its routes already; this reference only had to let go. + } +} + async function closeReconciledPage( context: BrowserClientPageReconciliationContext, event: BrowserClientHostCommandEvent, diff --git a/src/main/browser/browser-client-page-unavailability.test.ts b/src/main/browser/browser-client-page-unavailability.test.ts index 53f08d200460..2049901a9f8e 100644 --- a/src/main/browser/browser-client-page-unavailability.test.ts +++ b/src/main/browser/browser-client-page-unavailability.test.ts @@ -80,7 +80,8 @@ function harness() { })), selectRenderer: () => renderer, routeSessions: { - preparePage: vi.fn(async () => ({ partition, release: vi.fn() })) + preparePage: vi.fn(async () => ({ partition, release: vi.fn() })), + retargetPartitionProxy: vi.fn(async () => {}) }, routeWebContents: { claimGuestLifecycle: vi.fn((page: BrowserRoutePageGuestIdentity) => ({ diff --git a/src/main/browser/browser-host-lease-contact-loss.ts b/src/main/browser/browser-host-lease-contact-loss.ts new file mode 100644 index 000000000000..937da421aa81 --- /dev/null +++ b/src/main/browser/browser-host-lease-contact-loss.ts @@ -0,0 +1,20 @@ +/** + * A lease that ended because the runtime stopped answering within the reconnect grace, or answered + * with a different lease. Neither says the pages are gone, so the owner parks rather than closes. + */ +export class BrowserHostLeaseContactLostError extends Error { + constructor(cause: Error) { + super(cause.message, { cause }) + this.name = 'BrowserHostLeaseContactLostError' + } +} + +export function browserHostLeaseContactLost(error: unknown): BrowserHostLeaseContactLostError { + return new BrowserHostLeaseContactLostError( + error instanceof Error ? error : new Error(String(error)) + ) +} + +export function isBrowserHostLeaseContactLost(error: unknown): boolean { + return error instanceof BrowserHostLeaseContactLostError +} diff --git a/src/main/browser/browser-route-partition-storage-retirement.test.ts b/src/main/browser/browser-route-partition-storage-retirement.test.ts index 00cfb9f316c2..cc49d672684e 100644 --- a/src/main/browser/browser-route-partition-storage-retirement.test.ts +++ b/src/main/browser/browser-route-partition-storage-retirement.test.ts @@ -40,6 +40,7 @@ function stubExecutor(onClose: () => void) { completeAuthorityTransition: vi.fn(), fenceNavigation: vi.fn(), snapshotPageInventory: vi.fn(() => []), + releaseGuests: vi.fn(async () => {}), close: vi.fn(async () => { onClose() }) diff --git a/src/main/browser/browser-route-session-policy.ts b/src/main/browser/browser-route-session-policy.ts index 522ef57ba095..8b6b25dc2544 100644 --- a/src/main/browser/browser-route-session-policy.ts +++ b/src/main/browser/browser-route-session-policy.ts @@ -43,10 +43,7 @@ export async function prepareBrowserRouteSessionPolicy(input: { }) await proxySetup await session.closeAllConnections() - const resolved = await session.resolveProxy(PROXY_PROBE_URL) - if (resolved.trim() !== `SOCKS5 ${input.proxyEndpoint.host}:${input.proxyEndpoint.port}`) { - throw new Error('browser_route_partition_proxy_verification_failed') - } + await verifyBrowserRouteSessionProxy(session, input.proxyEndpoint) } catch (error) { await proxySetup?.catch(() => {}) try { @@ -64,6 +61,35 @@ export async function prepareBrowserRouteSessionPolicy(input: { return session } +/** + * Moves a prepared partition to a replacement proxy for the same execution host: a guest kept + * through a lease change keeps its DOM, but its old tunnel died with the old lease. Either way it + * stays on a loopback SOCKS proxy, so a failure leaves requests failing, never on the local network. + */ +export async function retargetBrowserRouteSessionProxy( + session: BrowserRouteElectronSession, + proxyEndpoint: BrowserRouteProxyEndpoint +): Promise { + assertBrowserRouteProxyEndpoint(proxyEndpoint) + await session.setProxy({ + mode: 'fixed_servers', + proxyRules: `socks5://${proxyEndpoint.host}:${proxyEndpoint.port}`, + proxyBypassRules: '<-loopback>' + }) + await session.closeAllConnections() + await verifyBrowserRouteSessionProxy(session, proxyEndpoint) +} + +async function verifyBrowserRouteSessionProxy( + session: BrowserRouteElectronSession, + proxyEndpoint: BrowserRouteProxyEndpoint +): Promise { + const resolved = await session.resolveProxy(PROXY_PROBE_URL) + if (resolved.trim() !== `SOCKS5 ${proxyEndpoint.host}:${proxyEndpoint.port}`) { + throw new Error('browser_route_partition_proxy_verification_failed') + } +} + export function assertBrowserRouteProxyEndpoint( endpoint: Readonly<{ host: string; port: number }> ): asserts endpoint is BrowserRouteProxyEndpoint { diff --git a/src/main/browser/browser-route-session-registry.ts b/src/main/browser/browser-route-session-registry.ts index 406e7e5b0456..99a2affae581 100644 --- a/src/main/browser/browser-route-session-registry.ts +++ b/src/main/browser/browser-route-session-registry.ts @@ -21,6 +21,7 @@ import { import { assertBrowserRouteProxyEndpoint, prepareBrowserRouteSessionPolicy, + retargetBrowserRouteSessionProxy, sameBrowserRouteProxyEndpoint, type BrowserRouteElectronSession, type BrowserRouteProxyEndpoint as ProxyEndpoint @@ -94,6 +95,18 @@ export class BrowserRouteSessionRegistry { ) } + /** See retargetBrowserRouteSessionProxy; a no-op when the partition already uses `proxyEndpoint`. */ + async retargetPartitionProxy(partition: string, proxyEndpoint: ProxyEndpoint): Promise { + const state = this.live.get(partition) + if (!state) { + throw new Error('browser_route_partition_unavailable') + } + if (!sameBrowserRouteProxyEndpoint(state.proxyEndpoint, proxyEndpoint)) { + await retargetBrowserRouteSessionProxy(state.session, proxyEndpoint) + state.proxyEndpoint = proxyEndpoint + } + } + retirePreparedPage(input: BrowserRoutePageAuthority): boolean { return retireBrowserRouteSessionPage(this.live, input, (state, page) => this.settlePageRetirement(state, page) diff --git a/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts b/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts new file mode 100644 index 000000000000..149a16db5021 --- /dev/null +++ b/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts @@ -0,0 +1,71 @@ +import type { + BrowserClientHostedPageInventory, + BrowserClientHostCommandEvent, + BrowserClientHostCommandResult, + BrowserClientHostLeaseAuthority +} from '../../shared/browser-client-host-protocol' +import type { BrowserClientPageNetworkRoute } from './browser-client-page-cleanup' +import type { BrowserClientPageAuthorityIdentity as BrowserClientHostAuthorityTransitionInput } from './browser-client-page-command-executor-dependencies' +import type { ComposedBrowserClientNetworkRoutes } from './paired-runtime-browser-client-host-route-sets' + +export type { BrowserClientHostAuthorityTransitionInput } + +export type ComposedPageExecutor = { + handle( + event: BrowserClientHostCommandEvent, + signal: AbortSignal + ): Promise + retirePage(browserPageId: string, pageHostGeneration: number): Promise + hasUnresolvedPage(browserPageId: string, pageHostGeneration: number): boolean + snapshotPageInventory(): readonly BrowserClientHostedPageInventory[] + beginAuthorityTransition(): void + completeAuthorityTransition(input: BrowserClientHostAuthorityTransitionInput): void + fenceNavigation(): void + close(): Promise +} + +export type ComposedClientHost = { + start(): Promise + retirePage(browserPageId: string, pageHostGeneration: number): Promise + forgetPage(browserPageId: string, pageHostGeneration: number): boolean + whenHandlersSettled(): Promise + refreshPageInventory(): Promise + close(error?: Error): Promise +} + +export type ClientHostCallbacks = { + handler( + event: BrowserClientHostCommandEvent, + signal: AbortSignal + ): Promise + onAuthority(authority: BrowserClientHostLeaseAuthority): void + getPageInventory(): readonly BrowserClientHostedPageInventory[] + onError(error: Error): void + onTransportLost(error: Error): void + onReconnected(authority: BrowserClientHostLeaseAuthority): void +} + +export type PairedRuntimeBrowserClientHostCompositionOptions< + Start extends BrowserClientHostAuthorityTransitionInput +> = { + initialInput: Start + createRoutes( + input: Start, + authority: BrowserClientHostLeaseAuthority + ): ComposedBrowserClientNetworkRoutes + createExecutor( + input: Start, + options: { + retainNetworkRoute( + executionHostKey: string, + signal: AbortSignal + ): Promise + onPageUnavailable(browserPageId: string, pageHostGeneration: number): void + } + ): ComposedPageExecutor + createHost(input: Start, callbacks: ClientHostCallbacks): ComposedClientHost + onError?: (error: Error) => void + /** Runs as closing begins, before teardown: the point after which this composition owns nothing. */ + onClosing?: () => void + parkedGuestDiscardMs?: number +} diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts index 01f26325f56c..d65dde1a13c6 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts @@ -4,7 +4,7 @@ import type { BrowserClientHostCommandEvent, BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' -import { BrowserClientHostAuthorityReplacementWait } from './browser-client-host-authority-replacement-wait' +import { BrowserHostLeaseContactLostError } from './browser-host-lease-contact-loss' import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browser-client-host-protocol' import { PairedRuntimeBrowserClientHostComposition } from './paired-runtime-browser-client-host-composition' @@ -284,7 +284,23 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { await composition.whenClosed() }) - it('waits out the grace instead of tearing down when the authority was replaced', async () => { + it('parks instead of tearing down when the lease loses contact', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + + rig.hostOptions.onError?.(contactLostError()) + + // Losing contact says nothing about the pages: they stay live and their routes stay suspended. + expect(composition.parked).toBe(true) + expect(rig.onError).not.toHaveBeenCalled() + expect(rig.executor.close).not.toHaveBeenCalled() + expect(rig.routes.close).not.toHaveBeenCalled() + expect(rig.order).toEqual(['activate-routes', 'suspend-routes', 'close-host']) + await composition.close() + }) + + it('parks when the runtime it named was replaced, with no deadline of its own', async () => { vi.useFakeTimers() try { const rig = createRig() @@ -292,114 +308,177 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { await composition.start() rig.hostOptions.onError?.(authorityReplacedError()) + vi.advanceTimersByTime(10 * 60_000) - // The guests are alive and still ours; the replacement runtime is on its way to reclaim them. - expect(rig.authorityReplacementWait.armed).toBe(true) + expect(composition.parked).toBe(true) expect(rig.onError).not.toHaveBeenCalled() - expect(rig.order).toEqual(['activate-routes']) + expect(rig.order).not.toContain('closing') + await composition.close() } finally { vi.useRealTimers() } }) - it('tears the composition down once the grace expires with no replacement', async () => { - vi.useFakeTimers() - try { - const rig = createRig() - const composition = rig.createComposition() - await composition.start() - const replaced = authorityReplacedError() + it('still tears down immediately for a host error that is not lost contact', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + const fatal = new Error('Stale browser host page command') - rig.hostOptions.onError?.(replaced) - vi.advanceTimersByTime(1_000) + rig.hostOptions.onError?.(fatal) - expect(rig.onError).toHaveBeenCalledWith(replaced) - expect(rig.order).toContain('closing') - await vi.runAllTimersAsync() - await composition.whenClosed() - } finally { - vi.useRealTimers() - } + expect(composition.parked).toBe(false) + expect(rig.onError).toHaveBeenCalledWith(fatal) + await composition.whenClosed() }) - it('cancels the grace when the replacement authority actually arrives', async () => { - vi.useFakeTimers() - try { - const rig = createRig() - const composition = rig.createComposition() - await composition.start() - rig.hostOptions.onError?.(authorityReplacedError()) + it('re-attaches a parked composition once per trigger, sharing concurrent triggers', async () => { + const rig = createRig({ + initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } + }) + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) - await composition.replaceAuthority(replacementInput) + const first = composition.resume() + const second = composition.resume() - // Released at the transition, not merely ignored when it fires: a deadline left running holds - // a timer for the whole grace and fires against a composition that has already moved on. - expect(rig.authorityReplacementWait.armed).toBe(false) - vi.advanceTimersByTime(10_000) - expect(rig.onError).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } + expect(second).toBe(first) + await first + expect(rig.hosts).toHaveLength(2) + expect(composition.parked).toBe(false) + // A runtime that can rekey kept guests gets them back in place: nothing is released or closed. + expect(rig.executor.retirePage).not.toHaveBeenCalled() + expect(rig.executor.close).not.toHaveBeenCalled() + expect(rig.order.slice(3)).toEqual([ + 'retire-routes', + 'fence-authority-transition', + 'close-host', + 'complete-authority-transition', + 'activate-routes' + ]) }) - it('cancels the grace when the composition closes for another reason', async () => { + it('leaves a live composition alone when a trigger fires', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + + await composition.resume() + + expect(rig.hosts).toHaveLength(1) + }) + + it('frees kept guests before re-attaching to a runtime that cannot rekey them', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + + await composition.resume() + + // The older runtime recreates each page at its last URL; a kept guest would be placed nowhere. + expect(rig.order.indexOf('retire-executor-page')).toBeLessThan( + rig.order.indexOf('complete-authority-transition') + ) + expect(rig.executor.close).not.toHaveBeenCalled() + }) + + it('stays parked when a re-attach fails, and the next trigger tries again', async () => { + const rig = createRig({ + initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } + }) + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + rig.failNextStart(new Error('remote runtime unavailable')) + + await expect(composition.resume()).rejects.toThrow('remote runtime unavailable') + + expect(composition.parked).toBe(true) + expect(rig.onError).not.toHaveBeenCalled() + expect(rig.executor.close).not.toHaveBeenCalled() + await expect(composition.resume()).resolves.toEqual( + expect.objectContaining({ authorityRuntimeId: 'runtime-a' }) + ) + expect(composition.parked).toBe(false) + expect(rig.hosts).toHaveLength(3) + }) + + it('frees parked guests after a long absence but keeps the composition for the return', async () => { vi.useFakeTimers() try { - const rig = createRig() + const rig = createRig({ parkedGuestDiscardMs: 60_000 }) const composition = rig.createComposition() await composition.start() - rig.hostOptions.onError?.(authorityReplacedError()) + rig.hostOptions.onError?.(contactLostError()) - await composition.close() + vi.advanceTimersByTime(59_999) + expect(rig.executor.retirePage).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) - expect(rig.authorityReplacementWait.armed).toBe(false) - vi.advanceTimersByTime(10_000) - // A grace that fired after close would report a second, bogus failure for a dead composition. - expect(rig.onError).not.toHaveBeenCalled() + expect(rig.executor.retirePage).toHaveBeenCalledWith('page-a', 7) + expect(composition.parked).toBe(true) + expect(rig.executor.close).not.toHaveBeenCalled() + await composition.close() } finally { vi.useRealTimers() } }) - it('arms one deadline for a burst of mismatch errors', async () => { + it('stops the discard timer once the runtime is back', async () => { vi.useFakeTimers() try { - const rig = createRig() + const rig = createRig({ + parkedGuestDiscardMs: 60_000, + initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } + }) const composition = rig.createComposition() await composition.start() + rig.hostOptions.onError?.(contactLostError()) - rig.hostOptions.onError?.(authorityReplacedError()) - vi.advanceTimersByTime(900) - rig.hostOptions.onError?.(authorityReplacedError()) - vi.advanceTimersByTime(100) + await composition.resume() + vi.advanceTimersByTime(120_000) - expect(rig.onError).toHaveBeenCalledTimes(1) - await vi.runAllTimersAsync() - await composition.whenClosed() + expect(rig.executor.retirePage).not.toHaveBeenCalled() } finally { vi.useRealTimers() } }) - it('still tears down immediately for a host error that is not a replacement', async () => { + it('releases a parked composition and its timer on close', async () => { vi.useFakeTimers() try { - const rig = createRig() + const rig = createRig({ parkedGuestDiscardMs: 60_000 }) const composition = rig.createComposition() await composition.start() - const fatal = new Error('browser host process exited') + rig.hostOptions.onError?.(contactLostError()) - rig.hostOptions.onError?.(fatal) + await composition.close() + vi.advanceTimersByTime(120_000) - expect(rig.authorityReplacementWait.armed).toBe(false) - expect(rig.onError).toHaveBeenCalledWith(fatal) - await vi.runAllTimersAsync() - await composition.whenClosed() + expect(composition.parked).toBe(false) + expect(rig.executor.close).toHaveBeenCalledOnce() + expect(rig.executor.retirePage).not.toHaveBeenCalled() + await expect(composition.resume()).rejects.toThrow('composition_closed') } finally { vi.useRealTimers() } }) + it('does not refresh inventory through a parked lease', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + + rig.reportPageUnavailable() + + expect(rig.host.refreshPageInventory).not.toHaveBeenCalled() + expect(rig.onError).not.toHaveBeenCalled() + await composition.close() + }) + it('fails closed without racing page cleanup when handlers do not settle', async () => { const rig = createRig({ hostSettled: false }) const composition = rig.createComposition() @@ -478,12 +557,12 @@ function createRig( pageInventory?: readonly BrowserClientHostedPageInventory[] replacementAuthority?: BrowserClientHostLeaseAuthority routeRetireError?: Error - authorityReplacementGraceMs?: number + initialAuthority?: BrowserClientHostLeaseAuthority + parkedGuestDiscardMs?: number } = {} ) { - const authorityReplacementWait = new BrowserClientHostAuthorityReplacementWait( - options.authorityReplacementGraceMs ?? 1_000 - ) + const initialAuthority = options.initialAuthority ?? authority + const startFailures: Error[] = [] const order: string[] = [] let onPageUnavailable = (_browserPageId: string, _pageHostGeneration: number): void => {} let settleHandlers = (): void => {} @@ -558,9 +637,13 @@ function createRig( let replacementInventory: readonly unknown[] = [] const makeHost = (callbacks: HostOptions, replacement: boolean) => ({ start: vi.fn(async () => { + const failure = startFailures.shift() + if (failure) { + throw failure + } const nextAuthority = replacement ? (options.replacementAuthority ?? replacementAuthority) - : authority + : initialAuthority if (replacement) { replacementInventory = callbacks.getPageInventory?.() ?? [] order.push('attach-replacement-inventory') @@ -586,7 +669,7 @@ function createRig( const onError = vi.fn() return { order, - authorityReplacementWait, + failNextStart: (error: Error) => startFailures.push(error), routes, replacementRoutes, executor, @@ -610,7 +693,7 @@ function createRig( createRoutes: (input, nextAuthority) => { const replacement = input === replacementInput expect(nextAuthority).toEqual( - replacement ? (options.replacementAuthority ?? replacementAuthority) : authority + replacement ? (options.replacementAuthority ?? replacementAuthority) : initialAuthority ) order.push(replacement ? 'activate-replacement-routes' : 'activate-routes') return replacement ? replacementRoutes : routes @@ -629,12 +712,20 @@ function createRig( onClosing: () => { order.push('closing') }, - createAuthorityReplacementWait: () => authorityReplacementWait, + ...(options.parkedGuestDiscardMs + ? { parkedGuestDiscardMs: options.parkedGuestDiscardMs } + : {}), onError }) } } +function contactLostError(): Error { + return new BrowserHostLeaseContactLostError( + new Error('Browser host lease reconnect grace expired.') + ) +} + function authorityReplacedError(): Error { return Object.assign(new Error('lease attach named a retired runtime'), { code: BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.ts b/src/main/browser/paired-runtime-browser-client-host-composition.ts index 4dfcada6068d..9caec8eb3052 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.ts @@ -5,78 +5,23 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' -import { BrowserClientHostAuthorityReplacementWait } from './browser-client-host-authority-replacement-wait' +import { + BrowserClientHostParking, + releaseBrowserClientGuests, + warnParkedCleanup +} from './browser-client-host-parking' +import { isBrowserHostLeaseContactLost } from './browser-host-lease-contact-loss' import { asCompositionError, closeBrowserClientHostComposition } from './paired-runtime-browser-client-host-teardown' -import type { BrowserClientPageNetworkRoute } from './browser-client-page-cleanup' -import type { BrowserClientPageAuthorityIdentity as BrowserClientHostAuthorityTransitionInput } from './browser-client-page-command-executor-dependencies' -import { - type ComposedBrowserClientNetworkRoutes, - PairedRuntimeBrowserClientHostRouteSets -} from './paired-runtime-browser-client-host-route-sets' - -type ComposedPageExecutor = { - handle( - event: BrowserClientHostCommandEvent, - signal: AbortSignal - ): Promise - retirePage(browserPageId: string, pageHostGeneration: number): Promise - hasUnresolvedPage(browserPageId: string, pageHostGeneration: number): boolean - snapshotPageInventory(): readonly BrowserClientHostedPageInventory[] - beginAuthorityTransition(): void - completeAuthorityTransition(input: BrowserClientHostAuthorityTransitionInput): void - fenceNavigation(): void - close(): Promise -} - -type ComposedClientHost = { - start(): Promise - retirePage(browserPageId: string, pageHostGeneration: number): Promise - forgetPage(browserPageId: string, pageHostGeneration: number): boolean - whenHandlersSettled(): Promise - refreshPageInventory(): Promise - close(error?: Error): Promise -} - -type ClientHostCallbacks = { - handler( - event: BrowserClientHostCommandEvent, - signal: AbortSignal - ): Promise - onAuthority(authority: BrowserClientHostLeaseAuthority): void - getPageInventory(): readonly BrowserClientHostedPageInventory[] - onError(error: Error): void - onTransportLost(error: Error): void - onReconnected(authority: BrowserClientHostLeaseAuthority): void -} - -type PairedRuntimeBrowserClientHostCompositionOptions< - Start extends BrowserClientHostAuthorityTransitionInput -> = { - initialInput: Start - createRoutes( - input: Start, - authority: BrowserClientHostLeaseAuthority - ): ComposedBrowserClientNetworkRoutes - createExecutor( - input: Start, - options: { - retainNetworkRoute( - executionHostKey: string, - signal: AbortSignal - ): Promise - onPageUnavailable(browserPageId: string, pageHostGeneration: number): void - } - ): ComposedPageExecutor - createHost(input: Start, callbacks: ClientHostCallbacks): ComposedClientHost - onError?: (error: Error) => void - /** Runs as closing begins, before teardown: the point after which this composition owns nothing. */ - onClosing?: () => void - /** Injected so the grace a restart depends on is drivable; production supplies the real deadline. */ - createAuthorityReplacementWait?: () => BrowserClientHostAuthorityReplacementWait -} +import { PairedRuntimeBrowserClientHostRouteSets } from './paired-runtime-browser-client-host-route-sets' +import type { + BrowserClientHostAuthorityTransitionInput, + ComposedClientHost, + ComposedPageExecutor, + PairedRuntimeBrowserClientHostCompositionOptions +} from './paired-runtime-browser-client-host-composition-contract' export class PairedRuntimeBrowserClientHostComposition< Start extends BrowserClientHostAuthorityTransitionInput @@ -91,11 +36,27 @@ export class PairedRuntimeBrowserClientHostComposition< private closed = false private errorReported = false private inventoryRefreshPromise: Promise | null = null - private readonly authorityReplacementWait: BrowserClientHostAuthorityReplacementWait + private readonly parking: BrowserClientHostParking constructor(private readonly options: PairedRuntimeBrowserClientHostCompositionOptions) { - this.authorityReplacementWait = - options.createAuthorityReplacementWait?.() ?? new BrowserClientHostAuthorityReplacementWait() + this.parking = new BrowserClientHostParking( + options.initialInput, + { + isClosed: () => this.closed, + retireLease: (input, error, releaseGuests) => this.retireLease(input, error, releaseGuests), + attach: (input) => { + this.host = this.createHost(input, true) + return this.host.start() + }, + suspend: (error) => { + this.routeSets.fence(error) + // Why not reportCleanupError: the owner treats any report as fatal and retires. + void this.host.close(error).catch(warnParkedCleanup) + }, + releaseGuests: () => releaseBrowserClientGuests(this.executor) + }, + options.parkedGuestDiscardMs + ) this.routeSets = new PairedRuntimeBrowserClientHostRouteSets({ createRoutes: options.createRoutes, onRecoveryError: (error) => this.handleHostError(error), @@ -116,23 +77,32 @@ export class PairedRuntimeBrowserClientHostComposition< return this.startPromise } + get parked(): boolean { + return this.parking.isParked + } + replaceAuthority(input: Start): Promise { if (this.closed) { return Promise.reject(new Error('paired_runtime_browser_client_host_composition_closed')) } - const error = new Error('Browser client host runtime authority was replaced') - this.authorityReplacementWait.cancel() - this.hostGeneration += 1 - try { - this.routeSets.retireCurrent(error) - this.executor.beginAuthorityTransition() - } catch (transitionError) { - return Promise.reject(transitionError) - } - this.startPromise = this.finishAuthorityReplacement(input, error) + this.startPromise = this.parking.replace(input) return this.startPromise } + /** One re-attach attempt for a parked composition; a live one answers with its current lease. */ + resume(): Promise { + const resumed = this.closed ? null : this.parking.resume() + if (resumed) { + this.startPromise = resumed + } + return resumed ?? this.start() + } + + /** Holds the pages for the runtime's return; only `close` gives them up. */ + park(error: Error): void { + this.parking.park(error) + } + async retirePage(browserPageId: string, pageHostGeneration: number): Promise { if (this.closed) { throw new Error('paired_runtime_browser_client_host_composition_closed') @@ -155,7 +125,7 @@ export class PairedRuntimeBrowserClientHostComposition< close(error = new Error('Browser client host composition is closed')): Promise { if (!this.closed) { this.closed = true - this.authorityReplacementWait.cancel() + this.parking.dispose() this.hostGeneration += 1 try { this.options.onClosing?.() @@ -198,6 +168,7 @@ export class PairedRuntimeBrowserClientHostComposition< throw new Error('browser_client_page_reconciliation_unsupported') } this.routeSets.activate(input, authority) + this.parking.noteAuthority(authority) } }, onTransportLost: (error) => { @@ -210,42 +181,40 @@ export class PairedRuntimeBrowserClientHostComposition< this.routeSets.reconnect(authority) } }, - // A replaced authority is armed rather than handled: handleHostError would close the - // composition and latch `errorReported`, swallowing the next genuinely fatal error. onError: (error) => { - const fatal = (): void => { - if (!this.closed && this.hostGeneration === generation) { - this.handleHostError(error) - } - } if (this.hostGeneration !== generation) { return } - if (this.closed || !isBrowserClientHostAuthorityReplaced(error)) { - this.handleHostError(error) + if ( + !this.closed && + (this.parking.isParked || + isBrowserHostLeaseContactLost(error) || + isBrowserClientHostAuthorityReplaced(error)) + ) { + this.park(error) return } - this.authorityReplacementWait.arm(fatal) + this.handleHostError(error) } }) } - private async finishAuthorityReplacement( - input: Start, - error: Error - ): Promise { - const previousHost = this.host - const settled = await previousHost.close(error) - if (!settled) { - await previousHost.whenHandlersSettled() - } - if (this.closed) { - throw new Error('paired_runtime_browser_client_host_composition_closed') + /** Drops the current lease and its routes but keeps the executor, so its guests survive. */ + private async retireLease(input: Start, error: Error, releaseGuests: boolean): Promise { + this.hostGeneration += 1 + this.routeSets.retireCurrent(error) + this.executor.beginAuthorityTransition() + try { + const previousHost = this.host + if (!(await previousHost.close(error).catch(() => false))) { + await previousHost.whenHandlersSettled() + } + if (releaseGuests) { + await releaseBrowserClientGuests(this.executor) + } + } finally { + this.executor.completeAuthorityTransition(input) } - this.executor.completeAuthorityTransition(input) - const replacementHost = this.createHost(input, true) - this.host = replacementHost - return replacementHost.start() } private async handleCommand( @@ -264,7 +233,8 @@ export class PairedRuntimeBrowserClientHostComposition< } private requestPageInventoryRefresh(): void { - if (this.closed || this.inventoryRefreshPromise) { + // A parked composition has no lease to refresh; its next attach carries a fresh inventory. + if (this.closed || this.parking.isParked || this.inventoryRefreshPromise) { return } const refresh = this.host.refreshPageInventory() diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts index a1cd4df5bad7..ad2e38805efd 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts @@ -80,6 +80,60 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { expect(replacement.start).toHaveBeenCalledOnce() }) + it('asks a parked environment to re-attach when a start names its current runtime', async () => { + const composition = createComposition() + const registry = new PairedRuntimeBrowserClientHostRegistry({ + createComposition: vi.fn(() => composition) + }) + await registry.start(input(11)) + composition.parked = true + + await expect(registry.start(input(11))).resolves.toEqual(authority) + + expect(composition.resume).toHaveBeenCalledOnce() + expect(composition.start).toHaveBeenCalledOnce() + }) + + it('keeps a parked environment registered when its re-attach fails', async () => { + const composition = createComposition() + composition.replaceAuthority.mockImplementationOnce(async () => { + composition.parked = true + throw new Error('remote runtime unavailable') + }) + const registry = new PairedRuntimeBrowserClientHostRegistry({ + createComposition: vi.fn(() => composition) + }) + await registry.start(input(11)) + + await expect(registry.start(input(11, 'runtime-b'))).rejects.toThrow( + 'remote runtime unavailable' + ) + + // Unreachable is not gone: the pages stay with the composition for the runtime's return. + expect(composition.close).not.toHaveBeenCalled() + composition.resume.mockResolvedValueOnce({ ...authority, authorityRuntimeId: 'runtime-b' }) + await expect(registry.start(input(11, 'runtime-b'))).resolves.toEqual( + expect.objectContaining({ authorityRuntimeId: 'runtime-b' }) + ) + }) + + it('resumes only parked environments when a trigger fires', async () => { + const parked = createComposition() + const live = createComposition() + const registry = new PairedRuntimeBrowserClientHostRegistry({ + createComposition: vi.fn().mockReturnValueOnce(parked).mockReturnValueOnce(live) + }) + await registry.start(input(11)) + await registry.start({ ...input(11), environmentId: 'environment-b' }) + parked.parked = true + + await registry.resumeParked() + await registry.resumeParked('environment-b') + + expect(parked.resume).toHaveBeenCalledOnce() + expect(live.resume).not.toHaveBeenCalled() + }) + it('blocks replacement when the old host cannot prove handler settlement', async () => { const cleanup = deferred() const first = createComposition(undefined, undefined, undefined, false, cleanup.promise) @@ -169,12 +223,15 @@ function createComposition( closed = Promise.resolve() ) { return { + parked: false, start: vi.fn(async () => { if (order && startLabel) { order.push(startLabel) } return authority }), + resume: vi.fn(async () => authority), + park: vi.fn(), replaceAuthority: vi.fn(async () => ({ ...authority, authorityRuntimeId: 'runtime-b' })), retirePage: vi.fn(async () => true), close: vi.fn(async () => { diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.ts b/src/main/browser/paired-runtime-browser-client-host-registry.ts index c34ee1a5cd99..b1957011ef49 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.ts @@ -9,6 +9,10 @@ export type PairedRuntimeBrowserClientHostStart = { type RegisteredBrowserClientHost = { start(): Promise replaceAuthority(input: Start): Promise + /** True while the runtime is unreachable and the composition is holding its pages for it. */ + readonly parked: boolean + resume(): Promise + park(error: Error): void retirePage(browserPageId: string, pageHostGeneration: number): Promise close(error?: Error): Promise whenClosed(): Promise @@ -54,6 +58,9 @@ export class PairedRuntimeBrowserClientHostRegistry< existing?.pairingRevision === input.pairingRevision && existing.authorityRuntimeId === input.authorityRuntimeId ) { + if (existing.composition.parked) { + existing.authority = existing.composition.resume() + } return existing.authority } if (existing?.pairingRevision === input.pairingRevision) { @@ -62,7 +69,10 @@ export class PairedRuntimeBrowserClientHostRegistry< try { return await existing.authority } catch (error) { - await this.cleanupFailedStart(input.environmentId, existing, error) + // A parked composition still holds the pages for the runtime's return. + if (!existing.composition.parked) { + await this.cleanupFailedStart(input.environmentId, existing, error) + } throw error } } @@ -116,6 +126,27 @@ export class PairedRuntimeBrowserClientHostRegistry< }) } + /** One re-attach attempt for each parked environment; live ones are untouched. */ + resumeParked(environmentId?: string): Promise { + const environmentIds = environmentId === undefined ? [...this.hosts.keys()] : [environmentId] + return Promise.all( + environmentIds.map((id) => + this.enqueue(id, async () => { + const record = this.hosts.get(id) + if (this.closed || !record?.composition.parked || record.cleanupPending) { + return + } + record.authority = record.composition.resume() + await record.authority.catch(() => undefined) + }) + ) + ).then(() => undefined) + } + + parkEnvironment(environmentId: string, error: Error): void { + this.hosts.get(environmentId)?.composition.park(error) + } + closeEnvironment(environmentId: string, error?: Error): Promise { return this.enqueue(environmentId, () => this.closeEnvironmentRecord(environmentId, error)) } diff --git a/src/main/browser/paired-runtime-browser-client-host-runtime.ts b/src/main/browser/paired-runtime-browser-client-host-runtime.ts index 0ea90f185d78..3a3b38ccc83e 100644 --- a/src/main/browser/paired-runtime-browser-client-host-runtime.ts +++ b/src/main/browser/paired-runtime-browser-client-host-runtime.ts @@ -85,6 +85,7 @@ const browserClientHosts = ) return new PairedRuntimeBrowserClientHostComposition({ onClosing: routes.release, + parkedGuestDiscardMs: e2eParkedGuestDiscardMs(), initialInput: input, createRoutes: (next, authority) => createNetworkRoutes(next.pairing, authority, next.storageScope, input.environmentId), @@ -123,6 +124,7 @@ const browserClientHosts = handler, getPageInventory, pageReconciliationProtocolVersion: 1, + returningHostReclaimProtocolVersion: 1, fileChannelProtocolVersion: 1, onAuthority, onTransportLost, @@ -229,6 +231,14 @@ export function retirePairedRuntimeBrowserClientHostEnvironment( return browserClientHosts.retireEnvironment(environmentId, error) } +/** + * Asks every parked environment (or just one) for a single re-attach. Callers are real events: + * the environment's connection coming back, wake, network online, or the user opening the tab. + */ +export function resumeParkedPairedRuntimeBrowserClientHosts(environmentId?: string): Promise { + return browserClientHosts.resumeParked(environmentId) +} + export function shutdownPairedRuntimeBrowserClientHosts(): Promise { clientHostRouteIdentities.clear() return browserClientHosts.close() @@ -250,9 +260,12 @@ function createNetworkRoutes( executionHost, executionHostRevision: executionHost.kind === 'native' ? executionHost.revision : 0, onError: reportBrowserClientHostError, - // Why: a route that stayed dark after bounded rebuilds leaves every page in the - // environment black-holing requests, so retire the host instead of warning forever. - onUnavailable: (error) => retireFailedEnvironmentHost(environmentId, error) + // Why park, not retire: a route that stayed dark is lost contact, not lost pages. Parking + // drops the lease so the next real trigger re-attaches with fresh routes. + onUnavailable: (error) => { + reportBrowserClientHostError(error) + browserClientHosts.parkEnvironment(environmentId, error) + } }) }) } @@ -310,6 +323,12 @@ function browserClientFileStagingRoot(environmentId: string): string { return path.join(app.getPath('temp'), 'orca-browser-file-channel', scope) } +/** Lets e2e reach the memory-bound path without waiting out the production hour. */ +function e2eParkedGuestDiscardMs(): number | undefined { + const configured = Number(process.env.ORCA_E2E_BROWSER_CLIENT_HOST_PARKED_DISCARD_MS) + return Number.isInteger(configured) && configured > 0 ? configured : undefined +} + function reportBrowserClientHostError(error: Error): void { console.warn('[browser-client-host] Client host unavailable:', error.message) } diff --git a/src/main/browser/paired-runtime-browser-client-host.ts b/src/main/browser/paired-runtime-browser-client-host.ts index eb19ad28485b..4453600ecf8a 100644 --- a/src/main/browser/paired-runtime-browser-client-host.ts +++ b/src/main/browser/paired-runtime-browser-client-host.ts @@ -22,6 +22,7 @@ export type PairedRuntimeBrowserClientHostOptions = { handler: CommandHandler getPageInventory?: () => readonly BrowserClientHostedPageInventory[] pageReconciliationProtocolVersion?: 1 + returningHostReclaimProtocolVersion?: 1 fileChannelProtocolVersion?: 1 dispatcher?: DispatcherLimits timeoutMs?: number @@ -60,6 +61,10 @@ export class PairedRuntimeBrowserClientHost { ...(options.pageReconciliationProtocolVersion ? { pageReconciliationProtocolVersion: options.pageReconciliationProtocolVersion } : {}), + ...(options.pageReconciliationProtocolVersion && + options.returningHostReclaimProtocolVersion + ? { returningHostReclaimProtocolVersion: options.returningHostReclaimProtocolVersion } + : {}), getPageInventory: options.getPageInventory } : {}), diff --git a/src/main/browser/paired-runtime-browser-host-lease-connection.ts b/src/main/browser/paired-runtime-browser-host-lease-connection.ts index 3b466f75cc06..e8d64d78a92d 100644 --- a/src/main/browser/paired-runtime-browser-host-lease-connection.ts +++ b/src/main/browser/paired-runtime-browser-host-lease-connection.ts @@ -206,6 +206,12 @@ export class PairedRuntimeBrowserHostLeaseConnection { ready.fileChannelProtocolVersion, request.fileChannelProtocolVersion ) || + !matchesOptionalProtocol( + ready.returningHostReclaimProtocolVersion, + request.returningHostReclaimProtocolVersion + ) || + (ready.returningHostReclaimProtocolVersion === 1 && + ready.pageReconciliationProtocolVersion !== 1) || (ready.fileChannelProtocolVersion === 1 && ready.pageCommandProtocolVersion !== 1) || (ready.leaseReconnectProtocolVersion === 1 && ready.pageInventoryProtocolVersion !== 1) || (ready.pageReconciliationProtocolVersion === 1 && @@ -285,6 +291,9 @@ function browserHostLeaseAuthority( : {}), ...(ready.fileChannelProtocolVersion ? { fileChannelProtocolVersion: ready.fileChannelProtocolVersion } + : {}), + ...(ready.returningHostReclaimProtocolVersion + ? { returningHostReclaimProtocolVersion: ready.returningHostReclaimProtocolVersion } : {}) }) } diff --git a/src/main/browser/paired-runtime-browser-host-lease-options.ts b/src/main/browser/paired-runtime-browser-host-lease-options.ts index 0c3f834d2318..15c211f5a15b 100644 --- a/src/main/browser/paired-runtime-browser-host-lease-options.ts +++ b/src/main/browser/paired-runtime-browser-host-lease-options.ts @@ -17,6 +17,7 @@ export type PairedRuntimeBrowserHostLeaseOptions = { pageReconciliationProtocolVersion?: 1 leaseReconnectProtocolVersion?: 1 fileChannelProtocolVersion?: 1 + returningHostReclaimProtocolVersion?: 1 getPageInventory?: () => readonly BrowserClientHostedPageInventory[] onPageCommand?: ( command: BrowserClientHostCommandEvent diff --git a/src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts b/src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts index 643567f53628..d799d3b9d8e6 100644 --- a/src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts +++ b/src/main/browser/paired-runtime-browser-host-lease-reconnect.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { isBrowserHostLeaseContactLost } from './browser-host-lease-contact-loss' import type { PairingOffer } from '../../shared/pairing' import type { RemoteRuntimeSubscription, @@ -405,6 +406,8 @@ describe('PairedRuntimeBrowserHostLease reconnect', () => { expect(onError).toHaveBeenCalledWith( expect.objectContaining({ message: expect.stringContaining('reconnect grace expired') }) ) + // Marked as lost contact, which the owner parks on instead of tearing the pages down. + expect(isBrowserHostLeaseContactLost(onError.mock.calls[0]![0])).toBe(true) expect(attempts.every((attempt) => attempt.close.mock.calls.length === 1)).toBe(true) expect(vi.getTimerCount()).toBe(0) }) diff --git a/src/main/browser/paired-runtime-browser-host-lease.ts b/src/main/browser/paired-runtime-browser-host-lease.ts index edfdf0f7a4f9..f95df34c232c 100644 --- a/src/main/browser/paired-runtime-browser-host-lease.ts +++ b/src/main/browser/paired-runtime-browser-host-lease.ts @@ -11,6 +11,7 @@ import { resolveBrowserHostReconnectDelay } from './browser-host-lease-reconnect-delay' import { reconnectBrowserHostLeaseUntil } from './browser-host-lease-reconnect-attempts' +import { browserHostLeaseContactLost } from './browser-host-lease-contact-loss' import { requireBrowserHostLeaseSendRequest } from './browser-host-lease-request-sender' import { submitBrowserHostCommandResult } from './browser-host-command-result-submission' import { @@ -176,7 +177,7 @@ export class PairedRuntimeBrowserHostLease { const reconnecting = this.reconnectUntil(Date.now() + this.reconnectGraceMs) this.reconnectPromise = reconnecting void reconnecting - .catch((reconnectError) => this.failTerminal(asError(reconnectError))) + .catch((reconnectError) => this.failTerminal(browserHostLeaseContactLost(reconnectError))) .finally(() => { if (this.reconnectPromise === reconnecting) { this.reconnectPromise = null diff --git a/src/main/ipc/runtime-environment-browser-client-host-handler.ts b/src/main/ipc/runtime-environment-browser-client-host-handler.ts index fb380c30c3d9..a280fed02900 100644 --- a/src/main/ipc/runtime-environment-browser-client-host-handler.ts +++ b/src/main/ipc/runtime-environment-browser-client-host-handler.ts @@ -7,16 +7,33 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { resolveEnvironment } from '../../shared/runtime-environment-store' import { closePairedRuntimeBrowserClientHostEnvironment, + resumeParkedPairedRuntimeBrowserClientHosts, startPairedRuntimeBrowserClientHost } from '../browser/paired-runtime-browser-client-host-runtime' import { prepareBrowserClientHostPlacement } from '../browser/browser-client-host-placement-preparation' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' +import { onRemoteRuntimeSharedControlReady } from './runtime-environment-shared-control-ready' + +let releaseSharedControlReadyListener: (() => void) | null = null export function registerRuntimeEnvironmentBrowserClientHostHandler(options: { getUserDataPath: () => string getSettings: () => Pick }): void { + releaseSharedControlReadyListener?.() + releaseSharedControlReadyListener = onRemoteRuntimeSharedControlReady((environmentId) => { + void resumeParkedPairedRuntimeBrowserClientHosts(environmentId) + }) + // The user opening a client-hosted tab is always allowed to ask for one re-attach. + ipcMain.handle('runtimeEnvironments:resumeBrowserClientHost', async (_event, input: unknown) => { + if (typeof input !== 'string' || input.length === 0) { + throw new Error('runtime_environment_id_required') + } + if (!isRuntimeEnvironmentManuallyDisconnected(input)) { + await resumeParkedPairedRuntimeBrowserClientHosts(input) + } + }) ipcMain.handle( 'runtimeEnvironments:prepareBrowserClientHostPlacement', async (_event, input: unknown): Promise => { diff --git a/src/main/ipc/runtime-environment-recovery-handler.ts b/src/main/ipc/runtime-environment-recovery-handler.ts index 7477956f9edd..e9b6dc129990 100644 --- a/src/main/ipc/runtime-environment-recovery-handler.ts +++ b/src/main/ipc/runtime-environment-recovery-handler.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import { retryRemoteRuntimeSharedControlConnectionsNow } from './runtime-environment-request-connections' +import { resumeParkedPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' const RETRY_CONNECTIONS_NOW_CHANNEL = 'runtimeEnvironments:retryConnectionsNow' @@ -7,5 +8,7 @@ export function registerRuntimeEnvironmentRecoveryHandler(): void { ipcMain.removeHandler(RETRY_CONNECTIONS_NOW_CHANNEL) ipcMain.handle(RETRY_CONNECTIONS_NOW_CHANNEL, () => { retryRemoteRuntimeSharedControlConnectionsNow() + // Why also here: a control connection that never noticed the outage will not report "ready". + void resumeParkedPairedRuntimeBrowserClientHosts() }) } diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 32b7b348d0ba..6bc7306d9f00 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -18,6 +18,7 @@ import type { import { isRuntimeEnvironmentCapabilityPaused } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { publishRuntimeEnvironmentDiagnostics } from './runtime-environment-diagnostics-broadcast' +import { notifyRemoteRuntimeSharedControlReady } from './runtime-environment-shared-control-ready' import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration @@ -210,6 +211,7 @@ function getSharedControlConnection( advanceRuntimeEnvironmentTransportGeneration(environmentId) cached?.connection.close() const transportGeneration = getRuntimeEnvironmentTransportGeneration(environmentId) + let ready = false cached = { pairingKey, connection: new RemoteRuntimeSharedControlConnection(pairing, { @@ -226,6 +228,11 @@ function getSharedControlConnection( transportGeneration, diagnostics }) + const wasReady = ready + ready = diagnostics.state === 'ready' + if (ready && !wasReady) { + notifyRemoteRuntimeSharedControlReady(environmentId) + } statusOwners .get(environmentId) ?.owner.connectionChanged( diff --git a/src/main/ipc/runtime-environment-shared-control-ready.ts b/src/main/ipc/runtime-environment-shared-control-ready.ts new file mode 100644 index 000000000000..4016797b7c91 --- /dev/null +++ b/src/main/ipc/runtime-environment-shared-control-ready.ts @@ -0,0 +1,19 @@ +const listeners = new Set<(environmentId: string) => void>() + +/** Fires each time an environment's shared control connection becomes ready again. */ +export function onRemoteRuntimeSharedControlReady( + listener: (environmentId: string) => void +): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function notifyRemoteRuntimeSharedControlReady(environmentId: string): void { + for (const listener of listeners) { + try { + listener(environmentId) + } catch (error) { + console.warn('[runtime-environments] shared control ready listener failed:', error) + } + } +} diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 6919c68f198a..f0a5f366c1f3 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -148,6 +148,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:disconnect', 'runtimeEnvironments:connect', 'runtimeEnvironments:retryControlConnection', + 'runtimeEnvironments:resumeBrowserClientHost', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:retryConnectionsNow', 'runtimeEnvironments:getStatus', diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index f158699ee47b..ee0055266e66 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -117,6 +117,8 @@ export type RuntimeApi = { ) => Promise // Why: system resume / browser online advance pending shared-control reconnect timers only. retryConnectionsNow?: () => Promise + /** Asks a parked client-hosted browser for one re-attach; a no-op when it is attached. */ + resumeBrowserClientHost?: (environmentId: string) => Promise call: (args: { selector: string method: string diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index 25cc43e1f61e..3c45dc03b7ac 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -82,6 +82,8 @@ export const runtimeEnvironmentsApi = { ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), retryConnectionsNow: (): Promise => ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), + resumeBrowserClientHost: (environmentId: string): Promise => + ipcRenderer.invoke('runtimeEnvironments:resumeBrowserClientHost', environmentId), call: (args: { selector: string method: string From beae7f21dd10db9160f7fa41d53387d443d1292c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 01:11:12 -0400 Subject: [PATCH 04/12] fix(remote-browser): say a client-hosted tab's host is offline instead of "another desktop" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While its host was unreachable a client-hosted tab either showed nothing or, once the guest was gone, "attached to a different desktop or is no longer available" with only a reopen-on-server escape. Now, while the environment's connection is down, a live page gets a " is offline — reconnecting" strip with Reconnect now, and a page whose guest is gone gets the same message as a notice rather than an error. Opening the tab, or clicking Reconnect now, asks main for one re-attach. "Open on a different desktop" is only said when the page's placement names another desktop's host id; otherwise the copy says the page is no longer available on this desktop. --- .../ClientHostedBrowserPagePane.tsx | 17 ++- ...hosted-browser-unavailable-notice.test.tsx | 88 +++++++++++ ...ient-hosted-browser-unavailable-notice.tsx | 142 ++++++++++++++++-- ...client-hosted-browser-host-reachability.ts | 40 +++++ src/renderer/src/i18n/locales/en.json | 10 +- src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - 10 files changed, 278 insertions(+), 24 deletions(-) create mode 100644 src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx create mode 100644 src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts diff --git a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx index bcc72d82308d..6b0572e9d227 100644 --- a/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx +++ b/src/renderer/src/components/browser-pane/ClientHostedBrowserPagePane.tsx @@ -20,7 +20,7 @@ import { useBrowserClientHostedDownloadNotices } from './browser-client-hosted-d import { useBrowserClientHostedPopupNotices } from './browser-client-hosted-popup-notices' import { useBrowserClientHostedPermissionNotices } from './browser-client-hosted-permission-notices' import { useClientHostedBrowserIntroTour } from './use-client-hosted-browser-intro-tour' -import { ClientHostedBrowserUnavailableNotice } from './client-hosted-browser-unavailable-notice' +import { ClientHostedBrowserAvailabilityNotice } from './client-hosted-browser-unavailable-notice' import { watchBrowserClientPageGuestLoss } from './host-guest/browser-client-page-guest-loss' import { useRestoredClientHostedRecoveryWindow } from './restored-client-hosted-recovery-window' import { useClientHostedBrowserMarkup } from './annotate/use-client-hosted-browser-markup' @@ -414,13 +414,14 @@ export function ClientHostedBrowserPagePane({ } /> ) : null} - {attachmentError || restoredPageUnrecovered ? ( - - ) : null} + ) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx new file mode 100644 index 000000000000..48bb924d2d6f --- /dev/null +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { resetBrowserClientHostIdForTests } from '@/runtime/browser-client-host-identity' +import { installClientHostedPaneApi } from './client-hosted-browser-pane-test-rig' +import { ClientHostedBrowserAvailabilityNotice } from './client-hosted-browser-unavailable-notice' + +const resumeBrowserClientHost = vi.fn(async () => {}) + +function renderNotice( + props: Partial[0]> = {} +) { + return render( + + ) +} + +function setHostOffline(offline: boolean): void { + useAppStore.setState({ + runtimeStatusByEnvironmentId: offline ? new Map([['env-a', { status: null }]]) : new Map(), + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the notice reads only id and name. + runtimeEnvironments: [{ id: 'env-a', name: 'Build box' }] as never + }) +} + +describe('ClientHostedBrowserAvailabilityNotice', () => { + beforeEach(() => { + resetBrowserClientHostIdForTests() + resumeBrowserClientHost.mockClear() + installClientHostedPaneApi({ + browser: { readClientHostId: () => 'this-desktop' }, + runtimeEnvironments: { resumeBrowserClientHost } + }) + }) + afterEach(() => { + cleanup() + setHostOffline(false) + }) + + it('stays out of the way of a live page whose host is reachable or not yet checked', () => { + setHostOffline(false) + const { container } = renderNotice() + + expect(container.innerHTML).toBe('') + }) + + it('marks a live page whose host is offline, and a click asks for a re-attach', () => { + setHostOffline(true) + renderNotice() + + expect(screen.getByRole('status').textContent).toContain('Build box is offline') + fireEvent.click(screen.getByRole('button', { name: 'Reconnect now' })) + expect(resumeBrowserClientHost).toHaveBeenCalledWith('env-a') + }) + + it('waits for an offline host instead of calling a missing guest unavailable', () => { + setHostOffline(true) + renderNotice({ guestUnavailable: true }) + + expect(screen.getByText('Build box is offline')).toBeTruthy() + expect(screen.queryByText('Client-hosted browser unavailable')).toBeNull() + }) + + it('names another desktop only when the placement actually names one', () => { + renderNotice({ guestUnavailable: true, browserHostClientId: 'other-desktop' }) + expect(screen.getByText('This page is open on a different desktop.')).toBeTruthy() + cleanup() + + renderNotice({ guestUnavailable: true, browserHostClientId: 'this-desktop' }) + expect(screen.getByText('This page is no longer available on this desktop.')).toBeTruthy() + expect(screen.queryByText(/different desktop/)).toBeNull() + }) + + it('asks a parked host to re-attach when the tab is opened', () => { + renderNotice({ isActive: true }) + + expect(resumeBrowserClientHost).toHaveBeenCalledWith('env-a') + }) +}) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx index fbd82cc7fdcc..01e7c1964fee 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx @@ -1,24 +1,106 @@ -import { Globe } from 'lucide-react' +import { Globe, Loader2, ServerOff } from 'lucide-react' import { translate } from '@/i18n/i18n' +import { Button } from '@/components/ui/button' import { ReopenBrowserPageOnServerButton, reopenOnServerCaveat } from './ReopenBrowserPageOnServerButton' +import { readBrowserClientHostId } from '@/runtime/browser-client-host-identity' +import { + requestClientHostedBrowserReattach, + useClientHostedBrowserHostReachability +} from './use-client-hosted-browser-host-reachability' + +/** + * Says why a client-hosted page is not serving: its host is offline (a strip over a still-live page, + * or a notice once the guest is gone), or the guest is gone for another reason. + */ +export function ClientHostedBrowserAvailabilityNotice({ + runtimeEnvironmentId, + worktreeId, + lastCommittedUrl, + guestUnavailable, + browserHostClientId, + isActive +}: { + runtimeEnvironmentId: string + worktreeId: string + lastCommittedUrl: string + guestUnavailable: boolean + browserHostClientId: string | null + isActive: boolean +}): React.JSX.Element | null { + const { hostOffline, hostName } = useClientHostedBrowserHostReachability({ + runtimeEnvironmentId, + isActive + }) + if (guestUnavailable) { + return ( + + ) + } + return hostOffline ? ( + + ) : null +} /** - * What a client-hosted page shows when its guest is not here: the page belongs to another desktop, - * or the host that owned it is gone. There is nothing to retry locally, so the only way forward - * offered is reopening the page on the server. + * What a client-hosted page shows when its guest is not here. The reason decides the copy: a host + * that is merely offline is waited for, never presented as an error, and "another desktop" is only + * said when the placement actually names one. */ export function ClientHostedBrowserUnavailableNotice({ runtimeEnvironmentId, worktreeId, - lastCommittedUrl + lastCommittedUrl, + reason, + hostName }: { runtimeEnvironmentId: string worktreeId: string lastCommittedUrl: string + reason: 'host-offline' | 'other-desktop' | 'unavailable' + hostName: string | null }): React.JSX.Element { + if (reason === 'host-offline') { + return ( +
+
+ +
{hostOfflineTitle(hostName)}
+
+ {translate( + 'browser.clientHosted.hostOfflineDescription', + 'Reconnecting. This page comes back when the host is reachable again.' + )} +
+ +
+
+ ) + } return (
@@ -27,10 +109,15 @@ export function ClientHostedBrowserUnavailableNotice({ {translate('browser.clientHosted.unavailableTitle', 'Client-hosted browser unavailable')}
- {translate( - 'browser.clientHosted.unavailableDescription', - 'This page is attached to a different desktop or is no longer available.' - )} + {reason === 'other-desktop' + ? translate( + 'browser.clientHosted.otherDesktopDescription', + 'This page is open on a different desktop.' + ) + : translate( + 'browser.clientHosted.noLongerAvailableDescription', + 'This page is no longer available on this desktop.' + )}
{reopenOnServerCaveat()}
) } + +/** Sits over a live page while its host is unreachable: the page stays, its network does not. */ +export function ClientHostedBrowserHostOfflineStrip({ + runtimeEnvironmentId, + hostName +}: { + runtimeEnvironmentId: string + hostName: string | null +}): React.JSX.Element { + return ( +
+ + + {hostOfflineTitle(hostName)} + {' — '} + {translate('browser.clientHosted.reconnecting', 'reconnecting')} + + +
+ ) +} + +function hostOfflineTitle(hostName: string | null): string { + return hostName + ? translate('browser.clientHosted.hostOfflineNamed', '{{host}} is offline', { host: hostName }) + : translate('browser.clientHosted.hostOffline', 'The host is offline') +} diff --git a/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts new file mode 100644 index 000000000000..4b80bdd29ffc --- /dev/null +++ b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts @@ -0,0 +1,40 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' + +/** Asks main for one re-attach of this environment's parked browser host; a no-op when attached. */ +export function requestClientHostedBrowserReattach(runtimeEnvironmentId: string): void { + void window.api.runtimeEnvironments + .resumeBrowserClientHost?.(runtimeEnvironmentId) + .catch(() => undefined) +} + +/** + * Whether the host behind a client-hosted page can be reached, and its display name. Opening the + * tab is one of the real events that asks a parked host to re-attach. + */ +export function useClientHostedBrowserHostReachability({ + runtimeEnvironmentId, + isActive +}: { + runtimeEnvironmentId: string + isActive: boolean +}): { hostOffline: boolean; hostName: string | null } { + // Why only a known entry: no status yet is "not checked", and that is not evidence of offline. + const hostOffline = useAppStore((s) => { + const entry = s.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId) + const state = runtimeHostConnectionStateForEntry(entry) + return entry !== undefined && (state === 'reconnecting' || state === 'disconnected') + }) + const hostName = useAppStore( + (s) => + s.runtimeEnvironments.find((environment) => environment.id === runtimeEnvironmentId)?.name ?? + null + ) + useEffect(() => { + if (isActive) { + requestClientHostedBrowserReattach(runtimeEnvironmentId) + } + }, [isActive, runtimeEnvironmentId]) + return { hostOffline, hostName } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 47fc41cca96b..52e1e64c4eb3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -82,7 +82,6 @@ "clientHosted": { "preparationFailed": "Couldn't start the remote browser on this desktop. Check the paired connection and try again.", "unavailableTitle": "Client-hosted browser unavailable", - "unavailableDescription": "This page is attached to a different desktop or is no longer available.", "download": { "started": "Downloading {{filename}}…", "completed": "Downloaded {{filename}}", @@ -100,7 +99,14 @@ "hostRowOfflineNamed": "Offline — was hosted on {{device}}", "hostRowOffline": "Offline — the device hosting it quit", "hostRowNamed": "Hosted on {{device}}", - "hostRow": "Hosted on another device" + "hostRow": "Hosted on another device", + "hostOfflineDescription": "Reconnecting. This page comes back when the host is reachable again.", + "reconnectNow": "Reconnect now", + "otherDesktopDescription": "This page is open on a different desktop.", + "noLongerAvailableDescription": "This page is no longer available on this desktop.", + "reconnecting": "reconnecting", + "hostOfflineNamed": "{{host}} is offline", + "hostOffline": "The host is offline" }, "navigation": { "back": "Back", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index ee8ad0eb4f7e..aec1b0014694 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -18581,7 +18581,6 @@ "clientHosted": { "preparationFailed": "No se pudo iniciar el navegador remoto en este escritorio. Comprueba la conexión emparejada y vuelve a intentarlo.", "unavailableTitle": "Navegador alojado en el cliente no disponible", - "unavailableDescription": "Esta página está asociada a otro escritorio o ya no está disponible.", "download": { "started": "Descargando {{filename}}…", "completed": "{{filename}} descargado", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index c1d2b60f7199..f78c70297f48 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -38,7 +38,6 @@ "clientHosted": { "preparationFailed": "Impossible de démarrer le navigateur distant sur ce bureau. Vérifiez la connexion couplée et réessayez.", "unavailableTitle": "Navigateur hébergé par le client indisponible", - "unavailableDescription": "Cette page est attachée à un autre bureau ou n'est plus disponible.", "download": { "started": "Téléchargement de {{filename}}…", "completed": "Téléchargé {{filename}}", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c49555516f1c..fa37f8213888 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -18581,7 +18581,6 @@ "clientHosted": { "preparationFailed": "このデスクトップ上でリモートブラウザを起動できませんでした。ペアリングされた接続を確認して、もう一度試してください。", "unavailableTitle": "クライアントホスト型ブラウザは利用不可", - "unavailableDescription": "このページは別のデスクトップに接続されているか、もう利用できません。", "download": { "started": "{{filename}}をダウンロード中…", "completed": "{{filename}}をダウンロードしました", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0fdd13b366c5..1a53792fc332 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -18581,7 +18581,6 @@ "clientHosted": { "preparationFailed": "이 데스크톱에서 원격 브라우저를 시작할 수 없습니다. 페어링된 연결을 확인하고 다시 시도하세요.", "unavailableTitle": "클라이언트 호스팅 브라우저를 사용할 수 없음", - "unavailableDescription": "이 페이지는 다른 데스크탑에 연결되어 있거나 더 이상 사용할 수 없습니다.", "download": { "started": "{{filename}} 다운로드 중…", "completed": "{{filename}}을(를) 다운로드했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a6e49bbbad4c..5062ec87cb09 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -18581,7 +18581,6 @@ "clientHosted": { "preparationFailed": "无法在此桌面上启动远程浏览器。检查配对连接并重试。", "unavailableTitle": "客户端托管的浏览器不可用", - "unavailableDescription": "此页面已附加到其他桌面或不再可用。", "download": { "started": "正在下载 {{filename}}...", "completed": "已下载{{filename}}", From 2f1bbecf762068772713071aa0092ca5ed9e6b62 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 01:15:53 -0400 Subject: [PATCH 05/12] test(remote-browser): pin that a client-hosted page survives an outage and comes back Strengthens the outage repro from "a page came back" to "the same page came back": a token stamped into the live document must read back after the host returns, the placement must be new, nothing may say "unavailable", and the page must load again. Adds the remaining journeys against a real paired host: - a 5s outage keeps today's lease generation and page; - while parked, a fetch from the page fails and the fixture server sees no request from any other route; - a host that quits and stays down past the grace gets the tab back on relaunch, showing "is offline" meanwhile; - a host that does not negotiate the reclaim (ORCA_E2E_DISABLE_RETURNING_ HOST_RECLAIM) gets the page back by reloading it; - a long absence (ORCA_E2E_BROWSER_CLIENT_HOST_PARKED_DISCARD_MS) frees the guest but keeps the tab, which reloads when the host returns; - a page closed on the host while the desktop was away is closed on the desktop when it returns. --- .../helpers/client-hosted-browser-fixture.ts | 7 +- .../helpers/headless-paired-runtime-host.ts | 3 +- ...ient-hosted-browser-network-outage.spec.ts | 466 +++++++++++++----- 3 files changed, 355 insertions(+), 121 deletions(-) diff --git a/tests/e2e/helpers/client-hosted-browser-fixture.ts b/tests/e2e/helpers/client-hosted-browser-fixture.ts index e4286818b54b..5df47a440eed 100644 --- a/tests/e2e/helpers/client-hosted-browser-fixture.ts +++ b/tests/e2e/helpers/client-hosted-browser-fixture.ts @@ -18,12 +18,16 @@ export type ClientHostedMarkerFixture = { /** A second page the guest reaches on its own, to tell "survived" from "survived where". */ movedUrl: string origin: string + /** Requests the server has answered, to prove a disconnected guest reached it by no other path. */ + requestCount(): number } export async function startClientHostedMarkerFixture( markers: { created: string; moved: string } = { created: 'survivor', moved: 'moved-on' } ): Promise { + let requests = 0 const server = createServer((request, response) => { + requests += 1 const marker = request.url === '/moved' ? markers.moved : markers.created response.writeHead(200, { 'cache-control': 'no-store', @@ -50,7 +54,8 @@ export async function startClientHostedMarkerFixture( }), markerUrl: `${origin}/survivor`, movedUrl: `${origin}/moved`, - origin + origin, + requestCount: () => requests } } diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 07cecbc8d051..0b341f1dd829 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -79,6 +79,7 @@ export async function launchHeadlessPairedRuntimeHost( options: { agentBrowserSocketParent?: string executablePath?: string + extraEnv?: Record /** Bind a stable loopback port so `restartServeProcess` can reclaim it. */ pinnedServePort?: boolean userDataParent?: string @@ -107,7 +108,7 @@ export async function launchHeadlessPairedRuntimeHost( ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1', ORCA_E2E_HEADLESS: '1' }, - extraEnv: {}, + extraEnv: options.extraEnv ?? {}, userDataDir }) if (agentBrowserSocketDir) { diff --git a/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts b/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts index 0f483141cf6e..d59900960d42 100644 --- a/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts +++ b/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts @@ -1,14 +1,15 @@ -import type { Page } from '@stablyai/playwright-test' +import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect, test } from './helpers/orca-app' -import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { + launchHeadlessPairedRuntimeHost, + type HeadlessPairedRuntimeHost +} from './helpers/headless-paired-runtime-host' import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' import { launchPairedElectronClient, type PairedElectronClient } from './helpers/paired-electron-client' import { - findMirroredBrowserPage, - focusClientBrowserRow, navigateGuest, readClientBrowserRows, openClientHostedFixturePage, @@ -16,7 +17,9 @@ import { selectPairedWorktreeGroup, startClientHostedMarkerFixture, waitForPairedWorktreeId, - waitForRenderedClientWebview + waitForRenderedClientWebview, + type ClientHostedMarkerFixture, + type MirroredBrowserPage } from './helpers/client-hosted-browser-fixture' import { startFreezableTcpProxy, type FreezableTcpProxy } from './helpers/freezable-tcp-proxy' import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing' @@ -26,6 +29,82 @@ import { decodePairingOffer, encodePairingOffer } from '../../src/shared/pairing * silent one first needs the host's heartbeat to reap the socket (three missed 15s probes). */ const OUTAGE_MS = { reset: 25_000, silent: 100_000 } as const +/** Past the client's own 15s reconnect grace, so the composition has parked. */ +const PARKED_AFTER_MS = 20_000 +const UNAVAILABLE_TITLE = 'Client-hosted browser unavailable' + +type OutageRig = { + fixture: ClientHostedMarkerFixture + host: HeadlessPairedRuntimeHost + client: PairedElectronClient + proxy: FreezableTcpProxy | null + worktreeId: string + opened: MirroredBrowserPage +} + +/** + * Pairs a client to a headless host and opens one client-hosted fixture page. The client dials + * through a cuttable proxy unless the test restarts the host itself. + */ +async function withOutageRig( + testInfo: TestInfo, + testRepoPath: string, + options: { + name: string + viaProxy?: boolean + pinnedServePort?: boolean + hostEnv?: Record + clientEnv?: Record + }, + body: (rig: OutageRig) => Promise +): Promise { + const fixture = await startClientHostedMarkerFixture({ created: 'outage-survivor', moved: 'x' }) + const host = await launchHeadlessPairedRuntimeHost({ + ...(options.pinnedServePort ? { pinnedServePort: true } : {}), + ...(options.hostEnv ? { extraEnv: options.hostEnv } : {}) + }) + let client: PairedElectronClient | null = null + let proxy: FreezableTcpProxy | null = null + try { + await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) + let pairingUrl = host.offer.pairingUrl + if (options.viaProxy !== false) { + const decoded = decodePairingOffer(pairingUrl) + if (!('endpoint' in decoded)) { + throw new Error('expected a direct pairing offer') + } + const endpoint = new URL(decoded.endpoint) + proxy = await startFreezableTcpProxy(endpoint.hostname, Number(endpoint.port)) + endpoint.port = String(proxy.port) + pairingUrl = encodePairingOffer({ ...decoded, endpoint: endpoint.toString() }) + } + client = await launchPairedElectronClient( + { pairingUrl }, + testInfo, + options.name, + options.clientEnv ? { extraEnv: options.clientEnv } : {} + ) + const worktreeId = await waitForPairedWorktreeId(client.page, testRepoPath) + await selectPairedWorktreeGroup(client.page, client.environmentId, worktreeId) + const opened = await openClientHostedFixturePage(client, worktreeId, fixture.markerUrl) + expect( + await waitForRenderedClientWebview( + client.page, + { urlPrefix: fixture.markerUrl, remotePageId: opened.remotePageId }, + 'client-hosted guest never rendered the fixture' + ) + ).toBe('outage-survivor') + await body({ fixture, host, client, proxy, worktreeId, opened }) + } finally { + if (client) { + await cleanupE2EDaemons(client.userDataDir).catch(() => undefined) + await client.dispose() + } + await proxy?.close() + await host.dispose() + await fixture.close() + } +} async function readPageHostGeneration( client: PairedElectronClient, @@ -37,138 +116,287 @@ async function readPageHostGeneration( }, localPageId) } -/** Leaves the browser tab for the terminal and comes back, so the pane remounts its guest. */ -async function remountBrowserTab( - page: Page, - worktreeId: string, - localPageId: string -): Promise { - await page.evaluate((worktreeId) => { - const state = window.__store!.getState() - const terminal = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( - (tab) => tab.contentType === 'terminal' +/** Runs a script in this page's own guest, or null when it has no live one. */ +async function evaluateInGuest(page: Page, remotePageId: string, script: string): Promise { + return page.evaluate( + async ({ remotePageId, script }) => { + const host = document.querySelector( + `[data-browser-client-page-id="${CSS.escape(remotePageId)}"]` + ) + for (const candidate of host?.querySelectorAll('webview') ?? []) { + try { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: selected by the webview tag name. + return await (candidate as Electron.WebviewTag).executeJavaScript(script) + } catch { + // The guest may still be attaching. + } + } + return null + }, + { remotePageId, script } + ) +} + +/** Marks the live document; only the same document, never a reload, can read it back. */ +async function stampGuest(rig: OutageRig): Promise { + const token = `kept-${Date.now()}` + expect( + await evaluateInGuest( + rig.client.page, + rig.opened.remotePageId, + `window.__outageToken = ${JSON.stringify(token)}` ) - if (terminal) { - state.activateTab(terminal.id) - } - }, worktreeId) - await page.waitForTimeout(1_000) - await focusClientBrowserRow(page, worktreeId, localPageId) - await page.waitForTimeout(3_000) - return page.getByText('Client-hosted browser unavailable').count() + ).toBe(token) + return token +} + +function readGuestStamp(rig: OutageRig): Promise { + return evaluateInGuest(rig.client.page, rig.opened.remotePageId, 'window.__outageToken ?? null') +} + +function waitForHostOfflineCopy(rig: OutageRig): Promise { + return expect( + rig.client.page.getByText(/is offline/), + 'the tab never said its host was offline' + ).not.toHaveCount(0, { timeout: 30_000 }) +} + +/** The page answers to the host again: its network goes through, and nothing says "unavailable". */ +async function expectPageServing(rig: OutageRig): Promise { + await expect(rig.client.page.getByText(UNAVAILABLE_TITLE)).toHaveCount(0) + await expect(rig.client.page.getByText(/is offline/)).toHaveCount(0, { timeout: 30_000 }) + // Retried because routes rebuild just after the lease returns; a load in that gap fails once. + await expect + .poll( + async () => { + await navigateGuest(rig.client.page, rig.fixture.origin, rig.fixture.movedUrl).catch( + () => undefined + ) + return readClientWebviewMarker(rig.client.page, { + urlPrefix: rig.fixture.origin, + remotePageId: rig.opened.remotePageId + }) + }, + { timeout: 30_000, message: 'the page could not load anything after the host returned' } + ) + .toBe('x') +} + +async function waitForNewPlacement(rig: OutageRig, generationBefore: number | null): Promise { + await expect + .poll(() => readPageHostGeneration(rig.client, rig.opened.localPageId), { + timeout: 90_000, + message: 'the same desktop never hosted its page again after the network returned' + }) + .not.toBe(generationBefore) } -/** - * Neither Orca process restarts; only the network between them goes away for longer than the lease - * grace. When it returns, the same desktop must host its page again on its own, instead of leaving - * the tab on "Client-hosted browser unavailable … attached to a different desktop" for good. - */ for (const mode of ['silent', 'reset'] as const) { - test(`the same desktop re-hosts its browser tab after a ${mode} network outage longer than the lease grace`, async ({ + test(`the same live page survives a ${mode} network outage longer than the lease grace`, async ({ testRepoPath }, testInfo) => { test.setTimeout(420_000) - const fixture = await startClientHostedMarkerFixture({ created: 'outage-survivor', moved: 'x' }) - const host = await launchHeadlessPairedRuntimeHost() - let client: PairedElectronClient | null = null - let proxy: FreezableTcpProxy | null = null - try { - await host.client.call('repo.add', { path: testRepoPath, kind: 'git' }) - const decoded = decodePairingOffer(host.offer.pairingUrl) - if (!('endpoint' in decoded)) { - throw new Error('expected a direct pairing offer') + await withOutageRig(testInfo, testRepoPath, { name: `${mode} outage` }, async (rig) => { + const token = await stampGuest(rig) + const hostPid = rig.host.app.process().pid + const generationBefore = await readPageHostGeneration(rig.client, rig.opened.localPageId) + expect(generationBefore).not.toBeNull() + + rig.proxy!.cut(mode) + if (mode === 'reset') { + await waitForHostOfflineCopy(rig) } - const endpoint = new URL(decoded.endpoint) - proxy = await startFreezableTcpProxy(endpoint.hostname, Number(endpoint.port)) - endpoint.port = String(proxy.port) - client = await launchPairedElectronClient( - { pairingUrl: encodePairingOffer({ ...decoded, endpoint: endpoint.toString() }) }, - testInfo, - `client-hosted ${mode} outage` - ) - const worktreeId = await waitForPairedWorktreeId(client.page, testRepoPath) - await selectPairedWorktreeGroup(client.page, client.environmentId, worktreeId) - const opened = await openClientHostedFixturePage(client, worktreeId, fixture.markerUrl) + await new Promise((resolve) => setTimeout(resolve, OUTAGE_MS[mode])) + rig.proxy!.restore() + + // Why the generation: a new placement proves the host took the page back under a new lease. + await waitForNewPlacement(rig, generationBefore) + // Why the stamp: it survives only if the host rekeyed the kept guest instead of reloading it. + expect(await readGuestStamp(rig), 'the page was reloaded instead of kept').toBe(token) + await expectPageServing(rig) + expect(rig.host.app.process().pid, 'the host must not have restarted').toBe(hostPid) + }) + }) +} + +test('a short outage inside the lease grace keeps the same lease and page', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + await withOutageRig(testInfo, testRepoPath, { name: 'short outage' }, async (rig) => { + const token = await stampGuest(rig) + const generationBefore = await readPageHostGeneration(rig.client, rig.opened.localPageId) + + rig.proxy!.cut('reset') + await new Promise((resolve) => setTimeout(resolve, 5_000)) + rig.proxy!.restore() + + await expect(rig.client.page.getByText(/is offline/)).toHaveCount(0, { timeout: 30_000 }) + expect(await readPageHostGeneration(rig.client, rig.opened.localPageId)).toBe(generationBefore) + expect(await readGuestStamp(rig)).toBe(token) + await expectPageServing(rig) + }) +}) + +test('a parked page never reaches the network except through its host', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + await withOutageRig(testInfo, testRepoPath, { name: 'parked network' }, async (rig) => { + rig.proxy!.cut('reset') + await new Promise((resolve) => setTimeout(resolve, PARKED_AFTER_MS)) + const requestsBefore = rig.fixture.requestCount() + + const probe = await evaluateInGuest( + rig.client.page, + rig.opened.remotePageId, + `fetch(${JSON.stringify(`${rig.fixture.movedUrl}?parked-probe`)}, { cache: 'no-store' })` + + `.then((response) => 'reached:' + response.status, (error) => 'failed:' + error)` + ) + + // Why both: the fetch must fail, and the server must not have seen it by another route. + expect(String(probe)).toMatch(/^failed:/) + expect(rig.fixture.requestCount()).toBe(requestsBefore) + rig.proxy!.restore() + await expectPageServing(rig) + }) +}) + +test('a host that quits and stays down past the grace gets the tab back when it relaunches', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(420_000) + await withOutageRig( + testInfo, + testRepoPath, + { name: 'host offline', viaProxy: false, pinnedServePort: true }, + async (rig) => { + await rig.host.restartServeProcess({ + betweenProcesses: async () => { + await waitForHostOfflineCopy(rig) + await new Promise((resolve) => setTimeout(resolve, PARKED_AFTER_MS)) + await expect(rig.client.page.getByText(UNAVAILABLE_TITLE)).toHaveCount(0) + } + }) + + // A relaunched host is a new runtime; reloading the page at its last URL is acceptable. expect( await waitForRenderedClientWebview( - client.page, - { urlPrefix: fixture.markerUrl, remotePageId: opened.remotePageId }, - 'client-hosted guest never rendered the fixture' + rig.client.page, + { urlPrefix: rig.fixture.markerUrl, remotePageId: rig.opened.remotePageId }, + 'the page never came back after its host relaunched' ) ).toBe('outage-survivor') - const hostPid = host.app.process().pid - const generationBefore = await readPageHostGeneration(client, opened.localPageId) - expect(generationBefore).not.toBeNull() + await expectPageServing(rig) + } + ) +}) - proxy.cut(mode) - await new Promise((resolve) => setTimeout(resolve, OUTAGE_MS[mode])) - proxy.restore() - - // Why the generation: only a newly minted placement proves the page was hosted again, rather - // than read before the voided lease's guest was torn down. - try { - await expect - .poll(() => readPageHostGeneration(client!, opened.localPageId), { - timeout: 90_000, - message: 'the same desktop never hosted its page again after the network returned' - }) - .not.toBe(generationBefore) - } catch (error) { - const evidence = { - generationBefore, - generationAfter: await readPageHostGeneration(client, opened.localPageId), - unavailableNotices: await client.page - .getByText('Client-hosted browser unavailable') - .count(), - liveGuestMarker: await readClientWebviewMarker(client.page, { - urlPrefix: fixture.markerUrl, - remotePageId: opened.remotePageId - }), - noticesAfterRemount: await remountBrowserTab(client.page, worktreeId, opened.localPageId), - clientRowsAfter: await readClientBrowserRows(client.page, worktreeId), - markerAfterNavigating: await navigateGuest( - client.page, - fixture.markerUrl, - fixture.movedUrl - ) - .then(() => client!.page.waitForTimeout(10_000)) - .then(() => - readClientWebviewMarker(client!.page, { - urlPrefix: fixture.origin, - remotePageId: opened.remotePageId - }) - ) - .catch((navigationError: unknown) => `navigation failed: ${String(navigationError)}`), - proxyEvents: proxy.events - } - console.log('[outage] evidence', JSON.stringify(evidence)) - testInfo.annotations.push({ - type: 'outage-evidence', - description: JSON.stringify(evidence) - }) - await client.page.screenshot({ path: testInfo.outputPath('after-outage.png') }) - throw error - } +test('an older host that cannot rekey a kept page gets it back by reloading it', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + await withOutageRig( + testInfo, + testRepoPath, + { name: 'older host', hostEnv: { ORCA_E2E_DISABLE_RETURNING_HOST_RECLAIM: '1' } }, + async (rig) => { + await stampGuest(rig) + const generationBefore = await readPageHostGeneration(rig.client, rig.opened.localPageId) + + rig.proxy!.cut('reset') + await new Promise((resolve) => setTimeout(resolve, OUTAGE_MS.reset)) + rig.proxy!.restore() + + await waitForNewPlacement(rig, generationBefore) expect( await waitForRenderedClientWebview( - client.page, - { urlPrefix: fixture.markerUrl, remotePageId: opened.remotePageId }, - 'the re-hosted page never rendered' + rig.client.page, + { urlPrefix: rig.fixture.markerUrl, remotePageId: rig.opened.remotePageId }, + 'the reloaded page never rendered' ) ).toBe('outage-survivor') - await expect(client.page.getByText('Client-hosted browser unavailable')).toHaveCount(0) + expect(await readGuestStamp(rig), 'an older host can only reload the page').toBeNull() + await expectPageServing(rig) + } + ) +}) + +test('a long absence frees the live page but keeps the tab to reload it', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + await withOutageRig( + testInfo, + testRepoPath, + { + name: 'long absence', + clientEnv: { ORCA_E2E_BROWSER_CLIENT_HOST_PARKED_DISCARD_MS: '10000' } + }, + async (rig) => { + await stampGuest(rig) + rig.proxy!.cut('reset') + await expect + .poll(() => readGuestStamp(rig), { + timeout: 60_000, + message: 'the parked guest was never freed' + }) + .toBeNull() + // The guest is gone but the tab stays, and it says why instead of "unavailable". expect( - (await findMirroredBrowserPage(client.page, worktreeId, fixture.markerUrl))?.placementKind - ).toBe('client') - expect(host.app.process().pid, 'the host must not have restarted').toBe(hostPid) - } finally { - if (client) { - await cleanupE2EDaemons(client.userDataDir).catch(() => undefined) - await client.dispose() - } - await proxy?.close() - await host.dispose() - await fixture.close() + (await readClientBrowserRows(rig.client.page, rig.worktreeId)).map((row) => row.pageId) + ).toContain(rig.opened.localPageId) + await waitForHostOfflineCopy(rig) + await expect(rig.client.page.getByText(UNAVAILABLE_TITLE)).toHaveCount(0) + + rig.proxy!.restore() + expect( + await waitForRenderedClientWebview( + rig.client.page, + { urlPrefix: rig.fixture.markerUrl, remotePageId: rig.opened.remotePageId }, + 'the freed page never came back at its last URL' + ) + ).toBe('outage-survivor') + await expectPageServing(rig) } + ) +}) + +test('a page closed on the host while this desktop was away is closed here when it returns', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + await withOutageRig(testInfo, testRepoPath, { name: 'closed while away' }, async (rig) => { + rig.proxy!.cut('reset') + await new Promise((resolve) => setTimeout(resolve, OUTAGE_MS.reset)) + await rig.host.client.call('browser.tabClose', { page: rig.opened.remotePageId }) + rig.proxy!.restore() + + // The host's answer decides: its closed page leaves this desktop too, guest and row. + await expect + .poll(() => countGuests(rig), { + timeout: 90_000, + message: 'the closed page kept a live guest on this desktop' + }) + .toBe(0) + await expect + .poll( + async () => + (await readClientBrowserRows(rig.client.page, rig.worktreeId)).some( + (row) => row.pageId === rig.opened.localPageId + ), + { timeout: 60_000, message: 'the closed page kept its row on this desktop' } + ) + .toBe(false) }) +}) + +/** Live guests for this page anywhere in the window, mounted or not. */ +async function countGuests(rig: OutageRig): Promise { + return rig.client.app.evaluate(({ webContents }, url) => { + return webContents + .getAllWebContents() + .filter((contents) => contents.getType() === 'webview' && contents.getURL().startsWith(url)) + .length + }, rig.fixture.markerUrl) } From d794a773d28b55ef596bb19131fbf7b72164cc60 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 01:45:25 -0400 Subject: [PATCH 06/12] refactor(remote-browser): drive park re-attach through the existing reconnect paths The first cut added its own trigger wiring: a shared-control "ready" listener module, a dedicated resume IPC with its own preload method, and a registry park-by-environment entry point. Each duplicated a path that already exists, so they are gone: - the host coming back reaches the renderer's existing same-runtime "contact regained" edge, which now runs the restarted-runtime hook (renamed ensureBrowserClientHostForReturningRuntime); its prepare IPC reaches the registry, which resumes a parked composition; - online, wake, opening the tab and Reconnect now all use the existing retryConnectionsNow IPC, which now also resumes parked hosts, skipping environments the user disconnected by hand; - a route that stays dark parks its own composition directly. --- ...ntime-browser-client-host-registry.test.ts | 5 ++-- ...ed-runtime-browser-client-host-registry.ts | 12 ++------ ...red-runtime-browser-client-host-runtime.ts | 29 +++++++++---------- ...environment-browser-client-host-handler.ts | 17 ----------- .../runtime-environment-recovery-handler.ts | 7 +++-- ...runtime-environment-request-connections.ts | 7 ----- ...untime-environment-shared-control-ready.ts | 19 ------------ .../ipc/runtime-environments-pairing.test.ts | 1 - src/preload/api/runtime-api.ts | 2 -- .../api/runtime-environments-bridge.ts | 2 -- ...hosted-browser-unavailable-notice.test.tsx | 10 +++---- ...ient-hosted-browser-unavailable-notice.tsx | 15 +++------- ...client-hosted-browser-host-reachability.ts | 10 +++---- ...tored-client-hosted-browser-host-attach.ts | 5 ++-- ...hosted-browser-host-restart-attach.test.ts | 24 +++++++-------- .../slices/runtime-status-snapshot.test.ts | 11 +++---- .../src/store/slices/runtime-status.ts | 7 +++-- 17 files changed, 62 insertions(+), 121 deletions(-) delete mode 100644 src/main/ipc/runtime-environment-shared-control-ready.ts diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts index ad2e38805efd..8eaf7fa4bcee 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts @@ -127,8 +127,8 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { await registry.start({ ...input(11), environmentId: 'environment-b' }) parked.parked = true - await registry.resumeParked() - await registry.resumeParked('environment-b') + await registry.resumeParked(() => true) + await registry.resumeParked((environmentId) => environmentId !== 'environment-a') expect(parked.resume).toHaveBeenCalledOnce() expect(live.resume).not.toHaveBeenCalled() @@ -231,7 +231,6 @@ function createComposition( return authority }), resume: vi.fn(async () => authority), - park: vi.fn(), replaceAuthority: vi.fn(async () => ({ ...authority, authorityRuntimeId: 'runtime-b' })), retirePage: vi.fn(async () => true), close: vi.fn(async () => { diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.ts b/src/main/browser/paired-runtime-browser-client-host-registry.ts index b1957011ef49..2ef3345281c1 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.ts @@ -12,7 +12,6 @@ type RegisteredBrowserClientHost = { /** True while the runtime is unreachable and the composition is holding its pages for it. */ readonly parked: boolean resume(): Promise - park(error: Error): void retirePage(browserPageId: string, pageHostGeneration: number): Promise close(error?: Error): Promise whenClosed(): Promise @@ -126,11 +125,10 @@ export class PairedRuntimeBrowserClientHostRegistry< }) } - /** One re-attach attempt for each parked environment; live ones are untouched. */ - resumeParked(environmentId?: string): Promise { - const environmentIds = environmentId === undefined ? [...this.hosts.keys()] : [environmentId] + /** One re-attach attempt for each parked environment the caller allows; live ones are untouched. */ + resumeParked(allows: (environmentId: string) => boolean): Promise { return Promise.all( - environmentIds.map((id) => + [...this.hosts.keys()].filter(allows).map((id) => this.enqueue(id, async () => { const record = this.hosts.get(id) if (this.closed || !record?.composition.parked || record.cleanupPending) { @@ -143,10 +141,6 @@ export class PairedRuntimeBrowserClientHostRegistry< ).then(() => undefined) } - parkEnvironment(environmentId: string, error: Error): void { - this.hosts.get(environmentId)?.composition.park(error) - } - closeEnvironment(environmentId: string, error?: Error): Promise { return this.enqueue(environmentId, () => this.closeEnvironmentRecord(environmentId, error)) } diff --git a/src/main/browser/paired-runtime-browser-client-host-runtime.ts b/src/main/browser/paired-runtime-browser-client-host-runtime.ts index 3a3b38ccc83e..5f7de4039986 100644 --- a/src/main/browser/paired-runtime-browser-client-host-runtime.ts +++ b/src/main/browser/paired-runtime-browser-client-host-runtime.ts @@ -83,12 +83,16 @@ const browserClientHosts = downloadRelay, (params) => executor?.recordPublishedPageUrl(params) ) - return new PairedRuntimeBrowserClientHostComposition({ + const composition = new PairedRuntimeBrowserClientHostComposition({ onClosing: routes.release, parkedGuestDiscardMs: e2eParkedGuestDiscardMs(), initialInput: input, + // Why park, not retire: a route that stayed dark is lost contact, not lost pages. createRoutes: (next, authority) => - createNetworkRoutes(next.pairing, authority, next.storageScope, input.environmentId), + createNetworkRoutes(next.pairing, authority, next.storageScope, (error) => { + reportBrowserClientHostError(error) + composition.park(error) + }), createExecutor: (next, { retainNetworkRoute, onPageUnavailable }) => { executor = new BrowserClientPageCommandExecutor({ orcaProfileId: next.orcaProfileId, @@ -137,6 +141,7 @@ const browserClientHosts = }, onError: (error) => retireFailedEnvironmentHost(input.environmentId, error) }) + return composition } }) @@ -231,12 +236,11 @@ export function retirePairedRuntimeBrowserClientHostEnvironment( return browserClientHosts.retireEnvironment(environmentId, error) } -/** - * Asks every parked environment (or just one) for a single re-attach. Callers are real events: - * the environment's connection coming back, wake, network online, or the user opening the tab. - */ -export function resumeParkedPairedRuntimeBrowserClientHosts(environmentId?: string): Promise { - return browserClientHosts.resumeParked(environmentId) +/** Asks each parked environment the caller allows for a single re-attach. */ +export function resumeParkedPairedRuntimeBrowserClientHosts( + allows: (environmentId: string) => boolean +): Promise { + return browserClientHosts.resumeParked(allows) } export function shutdownPairedRuntimeBrowserClientHosts(): Promise { @@ -248,7 +252,7 @@ function createNetworkRoutes( pairing: PairingOffer, authority: BrowserClientHostLeaseAuthority, authorityStorageKey: string, - environmentId: string + onUnavailable: (error: Error) => void ): BrowserClientNetworkRouteRegistry { return new BrowserClientNetworkRouteRegistry({ authority, @@ -260,12 +264,7 @@ function createNetworkRoutes( executionHost, executionHostRevision: executionHost.kind === 'native' ? executionHost.revision : 0, onError: reportBrowserClientHostError, - // Why park, not retire: a route that stayed dark is lost contact, not lost pages. Parking - // drops the lease so the next real trigger re-attaches with fresh routes. - onUnavailable: (error) => { - reportBrowserClientHostError(error) - browserClientHosts.parkEnvironment(environmentId, error) - } + onUnavailable }) }) } diff --git a/src/main/ipc/runtime-environment-browser-client-host-handler.ts b/src/main/ipc/runtime-environment-browser-client-host-handler.ts index a280fed02900..fb380c30c3d9 100644 --- a/src/main/ipc/runtime-environment-browser-client-host-handler.ts +++ b/src/main/ipc/runtime-environment-browser-client-host-handler.ts @@ -7,33 +7,16 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { resolveEnvironment } from '../../shared/runtime-environment-store' import { closePairedRuntimeBrowserClientHostEnvironment, - resumeParkedPairedRuntimeBrowserClientHosts, startPairedRuntimeBrowserClientHost } from '../browser/paired-runtime-browser-client-host-runtime' import { prepareBrowserClientHostPlacement } from '../browser/browser-client-host-placement-preparation' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' -import { onRemoteRuntimeSharedControlReady } from './runtime-environment-shared-control-ready' - -let releaseSharedControlReadyListener: (() => void) | null = null export function registerRuntimeEnvironmentBrowserClientHostHandler(options: { getUserDataPath: () => string getSettings: () => Pick }): void { - releaseSharedControlReadyListener?.() - releaseSharedControlReadyListener = onRemoteRuntimeSharedControlReady((environmentId) => { - void resumeParkedPairedRuntimeBrowserClientHosts(environmentId) - }) - // The user opening a client-hosted tab is always allowed to ask for one re-attach. - ipcMain.handle('runtimeEnvironments:resumeBrowserClientHost', async (_event, input: unknown) => { - if (typeof input !== 'string' || input.length === 0) { - throw new Error('runtime_environment_id_required') - } - if (!isRuntimeEnvironmentManuallyDisconnected(input)) { - await resumeParkedPairedRuntimeBrowserClientHosts(input) - } - }) ipcMain.handle( 'runtimeEnvironments:prepareBrowserClientHostPlacement', async (_event, input: unknown): Promise => { diff --git a/src/main/ipc/runtime-environment-recovery-handler.ts b/src/main/ipc/runtime-environment-recovery-handler.ts index e9b6dc129990..9e6e8afb1a84 100644 --- a/src/main/ipc/runtime-environment-recovery-handler.ts +++ b/src/main/ipc/runtime-environment-recovery-handler.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import { retryRemoteRuntimeSharedControlConnectionsNow } from './runtime-environment-request-connections' +import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { resumeParkedPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' const RETRY_CONNECTIONS_NOW_CHANNEL = 'runtimeEnvironments:retryConnectionsNow' @@ -8,7 +9,9 @@ export function registerRuntimeEnvironmentRecoveryHandler(): void { ipcMain.removeHandler(RETRY_CONNECTIONS_NOW_CHANNEL) ipcMain.handle(RETRY_CONNECTIONS_NOW_CHANNEL, () => { retryRemoteRuntimeSharedControlConnectionsNow() - // Why also here: a control connection that never noticed the outage will not report "ready". - void resumeParkedPairedRuntimeBrowserClientHosts() + // Online, wake, and the user opening or retrying a client-hosted tab all land here. + void resumeParkedPairedRuntimeBrowserClientHosts( + (environmentId) => !isRuntimeEnvironmentManuallyDisconnected(environmentId) + ) }) } diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 6bc7306d9f00..32b7b348d0ba 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -18,7 +18,6 @@ import type { import { isRuntimeEnvironmentCapabilityPaused } from './runtime-environment-capability-evidence' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' import { publishRuntimeEnvironmentDiagnostics } from './runtime-environment-diagnostics-broadcast' -import { notifyRemoteRuntimeSharedControlReady } from './runtime-environment-shared-control-ready' import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration @@ -211,7 +210,6 @@ function getSharedControlConnection( advanceRuntimeEnvironmentTransportGeneration(environmentId) cached?.connection.close() const transportGeneration = getRuntimeEnvironmentTransportGeneration(environmentId) - let ready = false cached = { pairingKey, connection: new RemoteRuntimeSharedControlConnection(pairing, { @@ -228,11 +226,6 @@ function getSharedControlConnection( transportGeneration, diagnostics }) - const wasReady = ready - ready = diagnostics.state === 'ready' - if (ready && !wasReady) { - notifyRemoteRuntimeSharedControlReady(environmentId) - } statusOwners .get(environmentId) ?.owner.connectionChanged( diff --git a/src/main/ipc/runtime-environment-shared-control-ready.ts b/src/main/ipc/runtime-environment-shared-control-ready.ts deleted file mode 100644 index 4016797b7c91..000000000000 --- a/src/main/ipc/runtime-environment-shared-control-ready.ts +++ /dev/null @@ -1,19 +0,0 @@ -const listeners = new Set<(environmentId: string) => void>() - -/** Fires each time an environment's shared control connection becomes ready again. */ -export function onRemoteRuntimeSharedControlReady( - listener: (environmentId: string) => void -): () => void { - listeners.add(listener) - return () => listeners.delete(listener) -} - -export function notifyRemoteRuntimeSharedControlReady(environmentId: string): void { - for (const listener of listeners) { - try { - listener(environmentId) - } catch (error) { - console.warn('[runtime-environments] shared control ready listener failed:', error) - } - } -} diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index f0a5f366c1f3..6919c68f198a 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -148,7 +148,6 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:disconnect', 'runtimeEnvironments:connect', 'runtimeEnvironments:retryControlConnection', - 'runtimeEnvironments:resumeBrowserClientHost', 'runtimeEnvironments:prepareBrowserClientHostPlacement', 'runtimeEnvironments:retryConnectionsNow', 'runtimeEnvironments:getStatus', diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index ee0055266e66..f158699ee47b 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -117,8 +117,6 @@ export type RuntimeApi = { ) => Promise // Why: system resume / browser online advance pending shared-control reconnect timers only. retryConnectionsNow?: () => Promise - /** Asks a parked client-hosted browser for one re-attach; a no-op when it is attached. */ - resumeBrowserClientHost?: (environmentId: string) => Promise call: (args: { selector: string method: string diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index 3c45dc03b7ac..25cc43e1f61e 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -82,8 +82,6 @@ export const runtimeEnvironmentsApi = { ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), retryConnectionsNow: (): Promise => ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), - resumeBrowserClientHost: (environmentId: string): Promise => - ipcRenderer.invoke('runtimeEnvironments:resumeBrowserClientHost', environmentId), call: (args: { selector: string method: string diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx index 48bb924d2d6f..121e7fe20f0b 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx @@ -6,7 +6,7 @@ import { resetBrowserClientHostIdForTests } from '@/runtime/browser-client-host- import { installClientHostedPaneApi } from './client-hosted-browser-pane-test-rig' import { ClientHostedBrowserAvailabilityNotice } from './client-hosted-browser-unavailable-notice' -const resumeBrowserClientHost = vi.fn(async () => {}) +const retryConnectionsNow = vi.fn(async () => {}) function renderNotice( props: Partial[0]> = {} @@ -35,10 +35,10 @@ function setHostOffline(offline: boolean): void { describe('ClientHostedBrowserAvailabilityNotice', () => { beforeEach(() => { resetBrowserClientHostIdForTests() - resumeBrowserClientHost.mockClear() + retryConnectionsNow.mockClear() installClientHostedPaneApi({ browser: { readClientHostId: () => 'this-desktop' }, - runtimeEnvironments: { resumeBrowserClientHost } + runtimeEnvironments: { retryConnectionsNow } }) }) afterEach(() => { @@ -59,7 +59,7 @@ describe('ClientHostedBrowserAvailabilityNotice', () => { expect(screen.getByRole('status').textContent).toContain('Build box is offline') fireEvent.click(screen.getByRole('button', { name: 'Reconnect now' })) - expect(resumeBrowserClientHost).toHaveBeenCalledWith('env-a') + expect(retryConnectionsNow).toHaveBeenCalled() }) it('waits for an offline host instead of calling a missing guest unavailable', () => { @@ -83,6 +83,6 @@ describe('ClientHostedBrowserAvailabilityNotice', () => { it('asks a parked host to re-attach when the tab is opened', () => { renderNotice({ isActive: true }) - expect(resumeBrowserClientHost).toHaveBeenCalledWith('env-a') + expect(retryConnectionsNow).toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx index 01e7c1964fee..b7c2dacdc480 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx @@ -51,12 +51,7 @@ export function ClientHostedBrowserAvailabilityNotice({ /> ) } - return hostOffline ? ( - - ) : null + return hostOffline ? : null } /** @@ -93,7 +88,7 @@ export function ClientHostedBrowserUnavailableNotice({ type="button" variant="outline" size="sm" - onClick={() => requestClientHostedBrowserReattach(runtimeEnvironmentId)} + onClick={() => requestClientHostedBrowserReattach()} > {translate('browser.clientHosted.reconnectNow', 'Reconnect now')} @@ -131,11 +126,9 @@ export function ClientHostedBrowserUnavailableNotice({ } /** Sits over a live page while its host is unreachable: the page stays, its network does not. */ -export function ClientHostedBrowserHostOfflineStrip({ - runtimeEnvironmentId, +function ClientHostedBrowserHostOfflineStrip({ hostName }: { - runtimeEnvironmentId: string hostName: string | null }): React.JSX.Element { return ( @@ -153,7 +146,7 @@ export function ClientHostedBrowserHostOfflineStrip({ type="button" variant="ghost" size="xs" - onClick={() => requestClientHostedBrowserReattach(runtimeEnvironmentId)} + onClick={() => requestClientHostedBrowserReattach()} > {translate('browser.clientHosted.reconnectNow', 'Reconnect now')} diff --git a/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts index 4b80bdd29ffc..20be9d4a2239 100644 --- a/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts +++ b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts @@ -2,11 +2,9 @@ import { useEffect } from 'react' import { useAppStore } from '@/store' import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' -/** Asks main for one re-attach of this environment's parked browser host; a no-op when attached. */ -export function requestClientHostedBrowserReattach(runtimeEnvironmentId: string): void { - void window.api.runtimeEnvironments - .resumeBrowserClientHost?.(runtimeEnvironmentId) - .catch(() => undefined) +/** Asks main for one re-attach of every parked browser host; a no-op for attached ones. */ +export function requestClientHostedBrowserReattach(): void { + void window.api.runtimeEnvironments.retryConnectionsNow?.().catch(() => undefined) } /** @@ -33,7 +31,7 @@ export function useClientHostedBrowserHostReachability({ ) useEffect(() => { if (isActive) { - requestClientHostedBrowserReattach(runtimeEnvironmentId) + requestClientHostedBrowserReattach() } }, [isActive, runtimeEnvironmentId]) return { hostOffline, hostName } diff --git a/src/renderer/src/runtime/restored-client-hosted-browser-host-attach.ts b/src/renderer/src/runtime/restored-client-hosted-browser-host-attach.ts index 6cdc9d853481..d55ef0c8c917 100644 --- a/src/renderer/src/runtime/restored-client-hosted-browser-host-attach.ts +++ b/src/renderer/src/runtime/restored-client-hosted-browser-host-attach.ts @@ -31,14 +31,15 @@ export async function ensureBrowserClientHostsForRestoredPages( } /** - * Re-claims hosting duty after the runtime process restarted under a new id. + * Re-claims hosting duty after the runtime came back: restarted under a new id, or reachable again + * after this desktop's browser host parked through the outage. * * The guests are still alive in this desktop's webviews, but the runtime that placed them is gone * and the replacement knows nothing about them. Preparing again drives the host registry down its * `replaceAuthority` path, which keeps the guests and re-attaches with a page inventory the new * runtime can adopt. Without this nothing observes the id change and the rows are simply lost. */ -export async function ensureBrowserClientHostForRestartedRuntime( +export async function ensureBrowserClientHostForReturningRuntime( state: RestoredBrowserHandleSource, environmentId: string ): Promise { diff --git a/src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts b/src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts index 5daf0c70cdfb..e913932a74ea 100644 --- a/src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts +++ b/src/renderer/src/runtime/restored-client-hosted-browser-host-restart-attach.test.ts @@ -9,7 +9,7 @@ import { } from '../store/slices/runtime-status' import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture' import { - ensureBrowserClientHostForRestartedRuntime, + ensureBrowserClientHostForReturningRuntime, ensureBrowserClientHostsForRestoredPages, resetRestoredBrowserClientHostAttachForTests } from './restored-client-hosted-browser-host-attach' @@ -60,7 +60,7 @@ function preparedEnvironmentIds(): string[] { return prepareBrowserClientHostPlacement.mock.calls.map((call) => call[0].selector) } -describe('ensureBrowserClientHostForRestartedRuntime', () => { +describe('ensureBrowserClientHostForReturningRuntime', () => { beforeEach(() => { resetRestoredBrowserClientHostAttachForTests() prepareBrowserClientHostPlacement.mockClear() @@ -75,7 +75,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { // Why: the guests are still alive in this desktop's webviews, so re-preparing drives the registry // down replaceAuthority and hands the replacement runtime an inventory it can adopt. it('prepares the host for an environment with a live client-placed page', async () => { - await ensureBrowserClientHostForRestartedRuntime( + await ensureBrowserClientHostForReturningRuntime( stateWith({ 'page-1': { environmentId: 'env-a', placement: CLIENT_PLACEMENT } }), 'env-a' ) @@ -89,7 +89,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { // Why: a staged page has no host-minted placement yet, but this desktop is already its host. it('prepares the host for a staged client-hosted page with no placement yet', async () => { - await ensureBrowserClientHostForRestartedRuntime( + await ensureBrowserClientHostForReturningRuntime( stateWith({ 'page-1': { environmentId: 'env-a', staged: true, stagedClientHosted: true } }), 'env-a' ) @@ -99,7 +99,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { // Why: a session-restored row that adoption has not spent yet is client-hosted just as surely. it('prepares the host for a restored client-hosted page with no placement yet', async () => { - await ensureBrowserClientHostForRestartedRuntime( + await ensureBrowserClientHostForReturningRuntime( stateWith({ 'page-1': { environmentId: 'env-a', restoredFromSession: true, restoredClientHosted: true } }), @@ -110,7 +110,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { }) it('prepares no host when the environment has no handles at all', async () => { - await ensureBrowserClientHostForRestartedRuntime(stateWith({}), 'env-a') + await ensureBrowserClientHostForReturningRuntime(stateWith({}), 'env-a') expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() }) @@ -118,7 +118,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { // Why: a server-hosted-only environment loses nothing to a runtime restart, so claiming hosting // duty for it would be work this desktop was never asked to do. it('prepares no host when the environment only has server-hosted pages', async () => { - await ensureBrowserClientHostForRestartedRuntime( + await ensureBrowserClientHostForReturningRuntime( stateWith({ 'page-1': { environmentId: 'env-a', placement: { kind: 'server' } } }), 'env-a' ) @@ -128,7 +128,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { // Why: one runtime restarting must not start a host on every other environment this client knows. it('prepares no host when the only client-hosted page belongs to another environment', async () => { - await ensureBrowserClientHostForRestartedRuntime( + await ensureBrowserClientHostForReturningRuntime( stateWith({ 'page-1': { environmentId: 'env-b', placement: CLIENT_PLACEMENT } }), 'env-a' ) @@ -137,7 +137,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { }) it('prepares only the restarted environment when several have client-hosted pages', async () => { - await ensureBrowserClientHostForRestartedRuntime( + await ensureBrowserClientHostForReturningRuntime( stateWith({ 'page-1': { environmentId: 'env-a', placement: CLIENT_PLACEMENT }, 'page-2': { environmentId: 'env-b', placement: CLIENT_PLACEMENT } @@ -155,7 +155,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) await expect( - ensureBrowserClientHostForRestartedRuntime( + ensureBrowserClientHostForReturningRuntime( stateWith({ 'page-1': { environmentId: 'env-a', placement: CLIENT_PLACEMENT } }), 'env-a' ) @@ -168,7 +168,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { // Why: a restart can be observed before the browser slice has published any handle map. it('tolerates a state with no handle map at all', async () => { - await expect(ensureBrowserClientHostForRestartedRuntime({}, 'env-a')).resolves.toBeUndefined() + await expect(ensureBrowserClientHostForReturningRuntime({}, 'env-a')).resolves.toBeUndefined() expect(prepareBrowserClientHostPlacement).not.toHaveBeenCalled() }) @@ -188,7 +188,7 @@ describe('ensureBrowserClientHostForRestartedRuntime', () => { const restored = ensureBrowserClientHostsForRestoredPages( stateWith({ 'page-1': { environmentId: 'env-a', restoredClientHosted: true } }) ) - const restarted = ensureBrowserClientHostForRestartedRuntime(state, 'env-a') + const restarted = ensureBrowserClientHostForReturningRuntime(state, 'env-a') releaseFirst() await Promise.all([restored, restarted]) diff --git a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts index 48973fedf936..445949d08481 100644 --- a/src/renderer/src/store/slices/runtime-status-snapshot.test.ts +++ b/src/renderer/src/store/slices/runtime-status-snapshot.test.ts @@ -10,12 +10,12 @@ import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host- import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' -import { ensureBrowserClientHostForRestartedRuntime } from '@/runtime/restored-client-hosted-browser-host-attach' +import { ensureBrowserClientHostForReturningRuntime } from '@/runtime/restored-client-hosted-browser-host-attach' vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } })) vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({ ensureBrowserClientHostsForRestoredPages: vi.fn(), - ensureBrowserClientHostForRestartedRuntime: vi.fn() + ensureBrowserClientHostForReturningRuntime: vi.fn() })) vi.mock('@/runtime/client-hosted-browser-close-intent-replay', () => ({ replayClientHostedBrowserCloseIntents: vi.fn() @@ -84,7 +84,8 @@ it('represents failed verification honestly without manufacturing a session rest viewer.getState().applyRuntimeHostStatusSnapshot(snapshot(3)) // Regaining contact on the same runtime is neither a new connection nor a new session: the // generation holds so the session mirror is not rebuilt (#19647), and only the contact epoch - // — the mirror's resubscribe trigger — moves. No restart hook, no toast. + // — the mirror's resubscribe trigger — moves. No toast; the returning-runtime hook runs once so a + // browser host parked through the outage re-attaches. expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( generation ) @@ -92,7 +93,7 @@ it('represents failed verification honestly without manufacturing a session rest expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.status?.runtimeId).toBe( 'rt-1' ) - expect(ensureBrowserClientHostForRestartedRuntime).not.toHaveBeenCalled() + expect(ensureBrowserClientHostForReturningRuntime).toHaveBeenCalledOnce() expect(toast.warning).not.toHaveBeenCalled() const reconnectedGeneration = viewer .getState() @@ -104,7 +105,7 @@ it('represents failed verification honestly without manufacturing a session rest expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.connectionGeneration).toBe( (reconnectedGeneration ?? 0) + 1 ) - expect(ensureBrowserClientHostForRestartedRuntime).toHaveBeenCalled() + expect(ensureBrowserClientHostForReturningRuntime).toHaveBeenCalledTimes(2) }) it('retains disconnect ordering and rejects publications for removed or replaced pairings', () => { diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index ee61d2aa0026..92abe8ce01a2 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -21,7 +21,7 @@ import { refreshRuntimeEnvironmentStatus } from './runtime-status-refresh' import * as runtimeStatusConnectionGeneration from './runtime-status-connection-generation' import { replayClientHostedBrowserCloseIntents } from '@/runtime/client-hosted-browser-close-intent-replay' import { - ensureBrowserClientHostForRestartedRuntime, + ensureBrowserClientHostForReturningRuntime, ensureBrowserClientHostsForRestoredPages } from '@/runtime/restored-client-hosted-browser-host-attach' import { applyRuntimeHostStatusSnapshot } from './runtime-status-snapshot' @@ -252,8 +252,9 @@ export const createRuntimeStatusSlice: StateCreator Date: Tue, 6 Oct 2026 01:45:27 -0400 Subject: [PATCH 07/12] test(remote-browser): pin both version skews and the close-only-on-a-clear-answer rule - A desktop asks for returning-host reclaim only alongside page reconciliation; a host echoes it only to a desktop that asked, so an older desktop sees exactly the ready it always did. - A host that still holds the page under a placement the desktop does not report leaves the kept guest alone; only a page the host no longer has at all is closed. --- ...browser-client-host-attach-request.test.ts | 22 +++++++++++++ .../rpc/methods/browser-client-host.test.ts | 33 +++++++++++++++++++ ...browser-client-page-returning-host.test.ts | 15 +++++++++ 3 files changed, 70 insertions(+) diff --git a/src/main/browser/browser-client-host-attach-request.test.ts b/src/main/browser/browser-client-host-attach-request.test.ts index 7bdac2aba123..7279d59b6b89 100644 --- a/src/main/browser/browser-client-host-attach-request.test.ts +++ b/src/main/browser/browser-client-host-attach-request.test.ts @@ -46,6 +46,28 @@ describe('browser client host attach request', () => { expect(attach.params).not.toHaveProperty('leaseReconnectProtocolVersion') }) + it('asks for returning-host reclaim only beside page reconciliation', () => { + const options = { + pairing, + authorityRuntimeId: 'runtime-a', + browserHostClientId: 'host-a', + hostCapabilities: ['webview'], + pageCommandProtocolVersion: 1 as const, + onPageCommand: vi.fn(() => ({ status: 'completed' as const })), + pageInventoryProtocolVersion: 1 as const, + getPageInventory: () => [], + returningHostReclaimProtocolVersion: 1 as const + } + + expect( + createBrowserClientHostAttachRequest({ ...options, pageReconciliationProtocolVersion: 1 }) + .params + ).toMatchObject({ returningHostReclaimProtocolVersion: 1 }) + expect(createBrowserClientHostAttachRequest(options).params).not.toHaveProperty( + 'returningHostReclaimProtocolVersion' + ) + }) + it('includes reconnect negotiation only beside an encoded inventory snapshot', () => { const attach = createBrowserClientHostAttachRequest({ pairing, diff --git a/src/main/runtime/rpc/methods/browser-client-host.test.ts b/src/main/runtime/rpc/methods/browser-client-host.test.ts index 6226f9c6dd39..51b0725c8b73 100644 --- a/src/main/runtime/rpc/methods/browser-client-host.test.ts +++ b/src/main/runtime/rpc/methods/browser-client-host.test.ts @@ -136,6 +136,39 @@ describe('browser.clientHost.attach RPC', () => { expect(JSON.parse(replies[1]!).result).not.toHaveProperty('pageCommandProtocolVersion') }) + it.each([ + ['a desktop that asks for it', { returningHostReclaimProtocolVersion: 1 }, true], + // Old desktops never send the field: the host must answer exactly as it did before. + ['an older desktop', {}, false] + ])('echoes returning-host reclaim only to %s', async (_label, extra, echoed) => { + const cleanups = new Map void>() + const hostRuntime = runtime(cleanups) + const dispatcher = new RpcDispatcher({ + runtime: hostRuntime, + methods: BROWSER_CLIENT_HOST_METHODS + }) + const replies: string[] = [] + const attach = request('host-a', 1, 1, 1) + const dispatch = dispatcher.dispatchStreaming( + { ...attach, params: { ...attach.params, pageReconciliationProtocolVersion: 1, ...extra } }, + (reply) => replies.push(reply), + { + connectionId: 'connection-a', + clientKind: 'runtime', + pairedDeviceId: 'device-a', + clientCapabilities: [BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY] + } + ) + + await vi.waitFor(() => expect(replies).toHaveLength(1)) + const ready = JSON.parse(replies[0]!).result + expect(ready).toMatchObject({ type: 'ready', pageReconciliationProtocolVersion: 1 }) + expect('returningHostReclaimProtocolVersion' in ready).toBe(echoed) + + cleanups.get('browser-client-host:host-a')?.() + await dispatch + }) + it('echoes and retains only an explicitly negotiated complete page inventory', async () => { const cleanups = new Map void>() const hostRuntime = runtime(cleanups) diff --git a/src/main/runtime/runtime-browser-client-page-returning-host.test.ts b/src/main/runtime/runtime-browser-client-page-returning-host.test.ts index e907614c88eb..7cc251bafe77 100644 --- a/src/main/runtime/runtime-browser-client-page-returning-host.test.ts +++ b/src/main/runtime/runtime-browser-client-page-returning-host.test.ts @@ -164,6 +164,21 @@ describe('a desktop returning after its lease was fenced', () => { expect(rig.leases.getPlacement('page-a')).toBeUndefined() }) + it('closes only on the host saying the page is gone, never on a record it cannot match', async () => { + const rig = fencedDesktop() + // The host still holds the page, just not under the placement the desktop reports: an + // ambiguous answer, so the kept guest must be left alone rather than closed. + const returning = returnDesktop(rig, [ + keptInventory(rig.placement, { pageHostGeneration: rig.placement.pageHostGeneration + 5 }) + ]) + + const result = await returning.adopt() + + expect(returning.commands).toEqual([]) + expect(result.adoptedPageIds).toEqual([]) + expect(rig.pages.getPage('page-a')?.placement).toEqual(rig.placement) + }) + it('reloads a kept guest the desktop could not vouch for', async () => { const rig = fencedDesktop() const returning = returnDesktop(rig, [ From dc589d9ad9fac06fb4f19f8c90a877de98b93ade Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 02:11:40 -0400 Subject: [PATCH 08/12] refactor(remote-browser): fold parking into the composition and drive re-attach per environment One state machine: park fences routes and closes the lease; resume re-attaches a parked composition and otherwise answers with the live lease. A refusal now closes instead of parking; only lost contact, a replaced runtime or no answer park. Guests are freed for an older runtime only after its attach shows it cannot rekey them, and the discard timer pauses during an attempt. Tab activation and Reconnect go through the per-environment prepare path, a dark route parks and immediately asks for one resume, and the global resume on retryConnectionsNow is gone. releasedPageIds moves into the adoption options. --- .../browser-client-host-parking.test.ts | 170 ------------------ .../browser/browser-client-host-parking.ts | 167 ----------------- .../browser-client-page-command-executor.ts | 12 +- .../browser-client-page-reconciliation.ts | 6 +- ...me-browser-client-host-composition.test.ts | 101 +++++++++-- ...runtime-browser-client-host-composition.ts | 144 +++++++++------ ...ntime-browser-client-host-registry.test.ts | 16 +- ...ed-runtime-browser-client-host-registry.ts | 33 ++-- ...red-runtime-browser-client-host-runtime.ts | 11 +- ...ed-runtime-browser-client-host-teardown.ts | 40 +++++ .../runtime-environment-recovery-handler.ts | 6 - .../browser-host-client-page-adoption.ts | 19 +- .../runtime/browser-host-lease-registry.ts | 25 ++- ...-host-page-reconciliation-orchestration.ts | 9 +- .../runtime-browser-client-page-adoption.ts | 6 +- ...hosted-browser-unavailable-notice.test.tsx | 32 +++- ...ient-hosted-browser-unavailable-notice.tsx | 15 +- ...client-hosted-browser-host-reachability.ts | 12 +- .../src/store/slices/runtime-status.ts | 6 +- 19 files changed, 320 insertions(+), 510 deletions(-) delete mode 100644 src/main/browser/browser-client-host-parking.test.ts delete mode 100644 src/main/browser/browser-client-host-parking.ts diff --git a/src/main/browser/browser-client-host-parking.test.ts b/src/main/browser/browser-client-host-parking.test.ts deleted file mode 100644 index 6d5786049443..000000000000 --- a/src/main/browser/browser-client-host-parking.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' -import { BrowserClientHostParking, releaseBrowserClientGuests } from './browser-client-host-parking' - -const authority: BrowserClientHostLeaseAuthority = { - authorityRuntimeId: 'runtime-a', - authorityEpoch: 'epoch-a', - browserHostClientId: 'client-a', - browserHostGeneration: 2 -} - -function createOwner() { - const order: string[] = [] - const attaches: { resolve(): void; reject(error: Error): void }[] = [] - const owner = { - closed: false, - isClosed: () => owner.closed, - retireLease: vi.fn(async (_input: string, _error: Error, releaseGuests: boolean) => { - order.push(releaseGuests ? 'retire-lease+release-guests' : 'retire-lease') - }), - attach: vi.fn( - (input: string) => - new Promise((resolve, reject) => { - order.push(`attach:${input}`) - attaches.push({ resolve: () => resolve(authority), reject }) - }) - ), - suspend: vi.fn(() => { - order.push('suspend') - }), - releaseGuests: vi.fn(async () => { - order.push('release-guests') - }) - } - return { owner, order, attaches } -} - -async function settleAttach(rig: ReturnType, index = 0): Promise { - await vi.waitFor(() => expect(rig.attaches[index]).toBeDefined()) - rig.attaches[index]!.resolve() -} - -describe('BrowserClientHostParking', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - afterEach(() => { - vi.useRealTimers() - }) - - it('suspends the lease on every loss but arms one memory discard', () => { - const rig = createOwner() - const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) - - parking.park(new Error('grace expired')) - parking.park(new Error('grace expired again')) - vi.advanceTimersByTime(999) - expect(rig.owner.releaseGuests).not.toHaveBeenCalled() - vi.advanceTimersByTime(1) - - expect(rig.order).toEqual(['suspend', 'suspend', 'release-guests']) - // Freeing memory decides nothing: the state stays parked, waiting for the host. - expect(parking.isParked).toBe(true) - }) - - it('does nothing for a closed owner and leaves no timer after dispose', () => { - const rig = createOwner() - const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) - - parking.park(new Error('lost')) - parking.dispose() - rig.owner.closed = true - parking.park(new Error('lost')) - vi.advanceTimersByTime(10_000) - - expect(parking.isParked).toBe(false) - expect(rig.owner.releaseGuests).not.toHaveBeenCalled() - expect(vi.getTimerCount()).toBe(0) - }) - - it('answers a trigger only while parked, and shares one attempt between triggers', async () => { - const rig = createOwner() - const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) - expect(parking.resume()).toBeNull() - parking.park(new Error('lost')) - parking.noteAuthority({ ...authority, returningHostReclaimProtocolVersion: 1 }) - - const first = parking.resume() - const second = parking.resume() - expect(second).toBe(first) - await settleAttach(rig) - - await expect(first).resolves.toEqual(authority) - expect(rig.order).toEqual(['suspend', 'retire-lease', 'attach:input-a']) - expect(parking.isParked).toBe(false) - vi.advanceTimersByTime(10_000) - expect(rig.owner.releaseGuests).not.toHaveBeenCalled() - }) - - it('frees kept guests before re-attaching to a runtime that cannot rekey them', async () => { - const rig = createOwner() - const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) - parking.noteAuthority(authority) - parking.park(new Error('lost')) - - const resumed = parking.resume() - await settleAttach(rig) - await resumed - - expect(rig.order).toEqual(['suspend', 'retire-lease+release-guests', 'attach:input-a']) - }) - - it('stays parked after a failed attempt, and the very next trigger tries again', async () => { - const rig = createOwner() - const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) - parking.park(new Error('lost')) - - const failed = parking.resume() - await vi.waitFor(() => expect(rig.attaches).toHaveLength(1)) - rig.attaches[0]!.reject(new Error('still unreachable')) - await expect(failed).rejects.toThrow('still unreachable') - expect(parking.isParked).toBe(true) - - const retried = parking.resume() - expect(retried).not.toBe(failed) - await settleAttach(rig, 1) - await expect(retried).resolves.toEqual(authority) - }) - - it('runs a replacement runtime only after the attempt in flight settles', async () => { - const rig = createOwner() - const parking = new BrowserClientHostParking('input-a', rig.owner, 1_000) - parking.park(new Error('lost')) - - const resumed = parking.resume() - const replaced = parking.replace('input-b') - await vi.waitFor(() => expect(rig.attaches).toHaveLength(1)) - expect(rig.order).not.toContain('attach:input-b') - rig.attaches[0]!.reject(new Error('authority mismatch')) - await expect(resumed).rejects.toThrow('authority mismatch') - await settleAttach(rig, 1) - - await expect(replaced).resolves.toEqual(authority) - expect(rig.order.at(-1)).toBe('attach:input-b') - }) -}) - -describe('releaseBrowserClientGuests', () => { - it('retires every guest the executor still holds and reports every failure', async () => { - const retirePage = vi.fn(async (browserPageId: string) => { - if (browserPageId === 'page-b') { - throw new Error('cleanup failed') - } - return true - }) - const executor = { - snapshotPageInventory: () => [ - { browserPageId: 'page-a', pageHostGeneration: 3 }, - { browserPageId: 'page-b', pageHostGeneration: 4 } - ], - retirePage - } - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: release reads only id and generation. - await expect(releaseBrowserClientGuests(executor as never)).rejects.toThrow(AggregateError) - - expect(retirePage).toHaveBeenCalledWith('page-a', 3) - expect(retirePage).toHaveBeenCalledWith('page-b', 4) - }) -}) diff --git a/src/main/browser/browser-client-host-parking.ts b/src/main/browser/browser-client-host-parking.ts deleted file mode 100644 index 364433395255..000000000000 --- a/src/main/browser/browser-client-host-parking.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { - BrowserClientHostedPageInventory, - BrowserClientHostLeaseAuthority -} from '../../shared/browser-client-host-protocol' - -/** How long a parked desktop keeps its live guests before freeing them; the tabs stay either way. */ -export const PARKED_GUEST_DISCARD_MS = 60 * 60 * 1000 - -type ParkedLeaseOwner = { - isClosed(): boolean - /** Drops the current lease and routes, keeping the executor; frees every guest when asked to. */ - retireLease(input: Start, error: Error, releaseGuests: boolean): Promise - attach(input: Start): Promise - /** Suspends routes and closes the lease, keeping every page. */ - suspend(error: Error): void - releaseGuests(): Promise -} - -/** - * The "runtime unreachable" state of a client-hosted browser composition, and the re-attach out - * of it. - * - * Losing contact is never evidence the pages are gone, so the composition parks instead of tearing - * down: guests stay, routes stay suspended (page traffic fails rather than leaving locally), and a - * real event (connection ready, wake, online, the user opening the tab) asks for one re-attach. - * Concurrent asks share that attempt. The only timer frees memory after a long absence; it decides - * nothing about whether a page still exists. - */ -export class BrowserClientHostParking { - private parked = false - private discardTimer: ReturnType | null = null - private attempt: { - key: Start - token: symbol - promise: Promise - } | null = null - /** Whether the runtime last attached to can rekey guests this desktop kept through a fence. */ - private returningHostReclaim = false - - constructor( - private input: Start, - private readonly owner: ParkedLeaseOwner, - private readonly discardAfterMs = PARKED_GUEST_DISCARD_MS - ) {} - - get isParked(): boolean { - return this.parked - } - - noteAuthority(authority: BrowserClientHostLeaseAuthority): void { - this.returningHostReclaim = authority.returningHostReclaimProtocolVersion === 1 - } - - park(error: Error): void { - if (this.owner.isClosed()) { - return - } - if (!this.parked) { - console.warn('[browser-client-host] runtime unreachable; keeping pages until it returns:', { - error: error.message - }) - this.parked = true - this.discardTimer = setTimeout(() => { - this.discardTimer = null - void this.owner.releaseGuests().catch(warnParkedCleanup) - }, this.discardAfterMs) - this.discardTimer.unref?.() - } - this.owner.suspend(error) - } - - /** A newer runtime took over: re-attach under it, keeping every live guest. */ - replace(input: Start): Promise { - this.input = input - return this.coalesce(input, false) - } - - /** One re-attach attempt, or null when not parked. */ - resume(): Promise | null { - return this.parked ? this.coalesce(this.input, true) : null - } - - dispose(): void { - this.parked = false - this.clearDiscardTimer() - } - - /** One attempt per input at a time; an attempt for a newer input waits for the current one. */ - private coalesce(input: Start, resuming: boolean): Promise { - const current = this.attempt - if (current && current.key === input) { - return current.promise - } - const settled = current ? current.promise.catch(() => undefined) : Promise.resolve() - const token = Symbol('browser-client-host-attempt') - // Cleared before callers resume, so a trigger right after a failed attempt starts a new one. - const promise = settled - .then(() => this.rehost(input, resuming)) - .finally(() => { - if (this.attempt?.token === token) { - this.attempt = null - } - }) - this.attempt = { key: input, token, promise } - return promise - } - - private async rehost(input: Start, resuming: boolean): Promise { - this.assertOpen() - // An older runtime cannot rekey a kept guest and would leave it placed nowhere; freeing it lets - // that runtime's own recovery recreate the page at its last URL instead. - const releaseGuests = resuming && !this.returningHostReclaim - await this.owner.retireLease( - input, - new Error('Browser client host is re-attaching'), - releaseGuests - ) - this.assertOpen() - try { - const authority = await this.owner.attach(input) - this.parked = false - this.clearDiscardTimer() - return authority - } catch (error) { - this.park(error instanceof Error ? error : new Error(String(error))) - throw error - } - } - - private assertOpen(): void { - if (this.owner.isClosed()) { - throw new Error('paired_runtime_browser_client_host_composition_closed') - } - } - - private clearDiscardTimer(): void { - if (this.discardTimer) { - clearTimeout(this.discardTimer) - this.discardTimer = null - } - } -} - -/** Frees every live guest; the runtime recreates each page at its last URL when it can. */ -export async function releaseBrowserClientGuests(executor: { - snapshotPageInventory(): readonly BrowserClientHostedPageInventory[] - retirePage(browserPageId: string, pageHostGeneration: number): Promise -}): Promise { - const results = await Promise.allSettled( - executor - .snapshotPageInventory() - .map((page) => executor.retirePage(page.browserPageId, page.pageHostGeneration)) - ) - const failures = results.flatMap((result) => - result.status === 'rejected' ? [result.reason] : [] - ) - if (failures.length > 0) { - throw new AggregateError(failures, 'Browser client guest release failed') - } -} - -export function warnParkedCleanup(error: unknown): void { - console.warn( - '[browser-client-host] parked cleanup failed:', - error instanceof Error ? error.message : String(error) - ) -} diff --git a/src/main/browser/browser-client-page-command-executor.ts b/src/main/browser/browser-client-page-command-executor.ts index bfad1f846478..78db5a3a582f 100644 --- a/src/main/browser/browser-client-page-command-executor.ts +++ b/src/main/browser/browser-client-page-command-executor.ts @@ -107,20 +107,14 @@ export class BrowserClientPageCommandExecutor { case 'restorePage': return executeBrowserClientPageReconciliationCommand( { - // routeWebContents, routeSessions and retainNetworkRoute come straight from here. - ...this.dependencies, + routeWebContents: this.dependencies.routeWebContents, + routeSessions: this.dependencies.routeSessions, + retainNetworkRoute: this.dependencies.retainNetworkRoute, pages: this.pages, failedPages: this.failedPages, assertAvailable: () => this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning), createPage: (command, commandSignal) => this.createPage(command, commandSignal), - navigate: (command, commandSignal) => - navigateBrowserClientPageCommand( - this.pages, - this.dependencies.routeWebContents, - command, - commandSignal - ), retirePage: (browserPageId, generation) => this.retirePage(browserPageId, generation), cleanupPage: (page, previousRendererPage) => this.cleanupPage(page, previousRendererPage) diff --git a/src/main/browser/browser-client-page-reconciliation.ts b/src/main/browser/browser-client-page-reconciliation.ts index 6e99e55a278e..cbf2f83439d2 100644 --- a/src/main/browser/browser-client-page-reconciliation.ts +++ b/src/main/browser/browser-client-page-reconciliation.ts @@ -9,6 +9,7 @@ import { } from './browser-client-page-command-admission' import { sameBrowserClientPageAuthority } from './browser-client-host-command-authority' import { BrowserClientPageCommandError } from './browser-client-page-command-failure' +import { navigateBrowserClientPageCommand } from './browser-client-page-command-execution' import { createBrowserClientPageInventory, updateBrowserClientPageInventoryCurrentUrl @@ -34,7 +35,6 @@ type BrowserClientPageReconciliationContext = { signal: AbortSignal ): Promise routeSessions: Pick - navigate: (event: BrowserClientHostCommandEvent, signal: AbortSignal) => Promise retirePage: (browserPageId: string, pageHostGeneration: number) => Promise cleanupPage: ( page: BrowserClientRetainedPage, @@ -225,7 +225,9 @@ async function restorePage( return } try { - await context.navigate( + await navigateBrowserClientPageCommand( + context.pages, + context.routeWebContents, { ...event, command: { type: 'navigate', url: event.command.url } }, signal ) diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts index d65dde1a13c6..8b2e7f70272f 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts @@ -292,7 +292,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { rig.hostOptions.onError?.(contactLostError()) // Losing contact says nothing about the pages: they stay live and their routes stay suspended. - expect(composition.parked).toBe(true) + expect(composition.isParked).toBe(true) expect(rig.onError).not.toHaveBeenCalled() expect(rig.executor.close).not.toHaveBeenCalled() expect(rig.routes.close).not.toHaveBeenCalled() @@ -310,7 +310,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { rig.hostOptions.onError?.(authorityReplacedError()) vi.advanceTimersByTime(10 * 60_000) - expect(composition.parked).toBe(true) + expect(composition.isParked).toBe(true) expect(rig.onError).not.toHaveBeenCalled() expect(rig.order).not.toContain('closing') await composition.close() @@ -327,12 +327,12 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { rig.hostOptions.onError?.(fatal) - expect(composition.parked).toBe(false) + expect(composition.isParked).toBe(false) expect(rig.onError).toHaveBeenCalledWith(fatal) await composition.whenClosed() }) - it('re-attaches a parked composition once per trigger, sharing concurrent triggers', async () => { + it('re-attaches a parked composition in place when the runtime can rekey kept guests', async () => { const rig = createRig({ initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } }) @@ -340,13 +340,9 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { await composition.start() rig.hostOptions.onError?.(contactLostError()) - const first = composition.resume() - const second = composition.resume() - - expect(second).toBe(first) - await first + await composition.resume() expect(rig.hosts).toHaveLength(2) - expect(composition.parked).toBe(false) + expect(composition.isParked).toBe(false) // A runtime that can rekey kept guests gets them back in place: nothing is released or closed. expect(rig.executor.retirePage).not.toHaveBeenCalled() expect(rig.executor.close).not.toHaveBeenCalled() @@ -359,6 +355,24 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { ]) }) + it('re-attaches straight away after a dark route parks a healthy lease', async () => { + const rig = createRig({ + initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } + }) + const composition = rig.createComposition() + await composition.start() + + // The route owner parks and then asks for one resume; a lease that still answers takes it. + composition.park(new Error('browser network route stayed unavailable')) + expect(rig.routes.suspend).toHaveBeenCalled() + await composition.resume() + + expect(composition.isParked).toBe(false) + expect(rig.hosts).toHaveLength(2) + expect(rig.onError).not.toHaveBeenCalled() + expect(rig.executor.retirePage).not.toHaveBeenCalled() + }) + it('leaves a live composition alone when a trigger fires', async () => { const rig = createRig() const composition = rig.createComposition() @@ -369,18 +383,24 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { expect(rig.hosts).toHaveLength(1) }) - it('frees kept guests before re-attaching to a runtime that cannot rekey them', async () => { + it('frees kept guests only once a re-attach shows the runtime cannot rekey them', async () => { const rig = createRig() const composition = rig.createComposition() await composition.start() rig.hostOptions.onError?.(contactLostError()) + rig.failNextStart(unreachableError()) + + await expect(composition.resume()).rejects.toThrow('remote runtime unavailable') + // No answer is no evidence about the runtime's version: the guests stay. + expect(rig.executor.retirePage).not.toHaveBeenCalled() await composition.resume() // The older runtime recreates each page at its last URL; a kept guest would be placed nowhere. - expect(rig.order.indexOf('retire-executor-page')).toBeLessThan( - rig.order.indexOf('complete-authority-transition') + expect(rig.order.indexOf('retire-executor-page')).toBeGreaterThan( + rig.order.lastIndexOf('activate-routes') ) + expect(rig.hosts.at(-1)!.refreshPageInventory).toHaveBeenCalledOnce() expect(rig.executor.close).not.toHaveBeenCalled() }) @@ -391,17 +411,17 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { const composition = rig.createComposition() await composition.start() rig.hostOptions.onError?.(contactLostError()) - rig.failNextStart(new Error('remote runtime unavailable')) + rig.failNextStart(unreachableError()) await expect(composition.resume()).rejects.toThrow('remote runtime unavailable') - expect(composition.parked).toBe(true) + expect(composition.isParked).toBe(true) expect(rig.onError).not.toHaveBeenCalled() expect(rig.executor.close).not.toHaveBeenCalled() await expect(composition.resume()).resolves.toEqual( expect.objectContaining({ authorityRuntimeId: 'runtime-a' }) ) - expect(composition.parked).toBe(false) + expect(composition.isParked).toBe(false) expect(rig.hosts).toHaveLength(3) }) @@ -418,7 +438,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { vi.advanceTimersByTime(1) expect(rig.executor.retirePage).toHaveBeenCalledWith('page-a', 7) - expect(composition.parked).toBe(true) + expect(composition.isParked).toBe(true) expect(rig.executor.close).not.toHaveBeenCalled() await composition.close() } finally { @@ -426,6 +446,45 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { } }) + it('closes a parked composition when the runtime refuses the re-attach', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + const refusal = Object.assign(new Error('pairing revoked'), { code: 'unauthorized' }) + rig.failNextStart(refusal) + + await expect(composition.resume()).rejects.toThrow('pairing revoked') + + // A clear answer is final: the guests go with the composition rather than waiting forever. + expect(composition.isParked).toBe(false) + expect(rig.onError).toHaveBeenCalledWith(refusal) + await composition.whenClosed() + expect(rig.executor.close).toHaveBeenCalledOnce() + }) + + it('pauses the discard timer during a re-attach and restarts it when that fails', async () => { + vi.useFakeTimers() + try { + const rig = createRig({ parkedGuestDiscardMs: 60_000 }) + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + vi.advanceTimersByTime(50_000) + rig.failNextStart(unreachableError()) + + await expect(composition.resume()).rejects.toThrow('remote runtime unavailable') + vi.advanceTimersByTime(59_999) + expect(rig.executor.retirePage).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) + + expect(rig.executor.retirePage).toHaveBeenCalledWith('page-a', 7) + await composition.close() + } finally { + vi.useRealTimers() + } + }) + it('stops the discard timer once the runtime is back', async () => { vi.useFakeTimers() try { @@ -457,7 +516,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { await composition.close() vi.advanceTimersByTime(120_000) - expect(composition.parked).toBe(false) + expect(composition.isParked).toBe(false) expect(rig.executor.close).toHaveBeenCalledOnce() expect(rig.executor.retirePage).not.toHaveBeenCalled() await expect(composition.resume()).rejects.toThrow('composition_closed') @@ -726,6 +785,12 @@ function contactLostError(): Error { ) } +function unreachableError(): Error { + return Object.assign(new Error('remote runtime unavailable'), { + code: 'remote_runtime_unavailable' + }) +} + function authorityReplacedError(): Error { return Object.assign(new Error('lease attach named a retired runtime'), { code: BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.ts b/src/main/browser/paired-runtime-browser-client-host-composition.ts index 9caec8eb3052..d93f082af6ec 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.ts @@ -5,15 +5,14 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' -import { - BrowserClientHostParking, - releaseBrowserClientGuests, - warnParkedCleanup -} from './browser-client-host-parking' +import { isRecoverableBrowserHostLeaseError } from './browser-host-admission-recovery' import { isBrowserHostLeaseContactLost } from './browser-host-lease-contact-loss' import { asCompositionError, - closeBrowserClientHostComposition + closeBrowserClientHostComposition, + releaseBrowserClientGuests, + scheduleParkedGuestDiscard, + warnParkedCleanup } from './paired-runtime-browser-client-host-teardown' import { PairedRuntimeBrowserClientHostRouteSets } from './paired-runtime-browser-client-host-route-sets' import type { @@ -36,27 +35,13 @@ export class PairedRuntimeBrowserClientHostComposition< private closed = false private errorReported = false private inventoryRefreshPromise: Promise | null = null - private readonly parking: BrowserClientHostParking + private input: Start + private lastAuthority: BrowserClientHostLeaseAuthority | null = null + /** Set while the runtime is unreachable: pages and suspended routes are kept for its return. */ + private parked: { discardTimer: ReturnType | null } | null = null constructor(private readonly options: PairedRuntimeBrowserClientHostCompositionOptions) { - this.parking = new BrowserClientHostParking( - options.initialInput, - { - isClosed: () => this.closed, - retireLease: (input, error, releaseGuests) => this.retireLease(input, error, releaseGuests), - attach: (input) => { - this.host = this.createHost(input, true) - return this.host.start() - }, - suspend: (error) => { - this.routeSets.fence(error) - // Why not reportCleanupError: the owner treats any report as fatal and retires. - void this.host.close(error).catch(warnParkedCleanup) - }, - releaseGuests: () => releaseBrowserClientGuests(this.executor) - }, - options.parkedGuestDiscardMs - ) + this.input = options.initialInput this.routeSets = new PairedRuntimeBrowserClientHostRouteSets({ createRoutes: options.createRoutes, onRecoveryError: (error) => this.handleHostError(error), @@ -77,30 +62,48 @@ export class PairedRuntimeBrowserClientHostComposition< return this.startPromise } - get parked(): boolean { - return this.parking.isParked + get isParked(): boolean { + return this.parked !== null } replaceAuthority(input: Start): Promise { if (this.closed) { return Promise.reject(new Error('paired_runtime_browser_client_host_composition_closed')) } - this.startPromise = this.parking.replace(input) + this.input = input + this.startPromise = this.reattach(input, false) return this.startPromise } - /** One re-attach attempt for a parked composition; a live one answers with its current lease. */ + /** One re-attach for a parked composition; a live one answers with its current lease. */ resume(): Promise { - const resumed = this.closed ? null : this.parking.resume() - if (resumed) { - this.startPromise = resumed + if (this.closed || !this.parked) { + return this.start() } - return resumed ?? this.start() + this.startPromise = this.reattach(this.input, true) + return this.startPromise } /** Holds the pages for the runtime's return; only `close` gives them up. */ park(error: Error): void { - this.parking.park(error) + if (this.closed) { + return + } + if (!this.parked) { + console.warn('[browser-client-host] runtime unreachable; keeping pages until it returns:', { + error: error.message + }) + } + this.parked ??= { discardTimer: null } + this.parked.discardTimer ??= scheduleParkedGuestDiscard( + this.executor, + this.options.parkedGuestDiscardMs + ) + // The dead lease's late callbacks must not reach the kept guests. + this.hostGeneration += 1 + this.routeSets.fence(error) + // Why not reportCleanupError: the owner treats any report as fatal and retires. + void this.host.close(error).catch(warnParkedCleanup) } async retirePage(browserPageId: string, pageHostGeneration: number): Promise { @@ -125,7 +128,8 @@ export class PairedRuntimeBrowserClientHostComposition< close(error = new Error('Browser client host composition is closed')): Promise { if (!this.closed) { this.closed = true - this.parking.dispose() + this.clearGuestDiscard() + this.parked = null this.hostGeneration += 1 try { this.options.onClosing?.() @@ -168,7 +172,7 @@ export class PairedRuntimeBrowserClientHostComposition< throw new Error('browser_client_page_reconciliation_unsupported') } this.routeSets.activate(input, authority) - this.parking.noteAuthority(authority) + this.lastAuthority = authority } }, onTransportLost: (error) => { @@ -185,22 +189,21 @@ export class PairedRuntimeBrowserClientHostComposition< if (this.hostGeneration !== generation) { return } - if ( - !this.closed && - (this.parking.isParked || - isBrowserHostLeaseContactLost(error) || - isBrowserClientHostAuthorityReplaced(error)) - ) { - this.park(error) - return - } - this.handleHostError(error) + this.parkOrFail(error) } }) } - /** Drops the current lease and its routes but keeps the executor, so its guests survive. */ - private async retireLease(input: Start, error: Error, releaseGuests: boolean): Promise { + /** + * Swaps the lease for a fresh one under `input`, keeping the executor so its guests survive; the + * attach inventory is what lets the runtime rekey them in place. + */ + private async reattach( + input: Start, + sameRuntime: boolean + ): Promise { + this.clearGuestDiscard() + const error = new Error('Browser client host is re-attaching') this.hostGeneration += 1 this.routeSets.retireCurrent(error) this.executor.beginAuthorityTransition() @@ -209,12 +212,49 @@ export class PairedRuntimeBrowserClientHostComposition< if (!(await previousHost.close(error).catch(() => false))) { await previousHost.whenHandlersSettled() } - if (releaseGuests) { - await releaseBrowserClientGuests(this.executor) - } } finally { this.executor.completeAuthorityTransition(input) } + if (this.closed) { + throw new Error('paired_runtime_browser_client_host_composition_closed') + } + try { + this.host = this.createHost(input, true) + const authority = await this.host.start() + // An older runtime cannot rekey a kept guest and leaves it placed nowhere. Only its answer + // says so; freeing the guests and republishing lets its recovery reload them at their URL. + if (sameRuntime && authority.returningHostReclaimProtocolVersion !== 1) { + await releaseBrowserClientGuests(this.executor) + await this.host.refreshPageInventory() + } + this.parked = null + return authority + } catch (attachError) { + this.parkOrFail(asCompositionError(attachError)) + throw attachError + } + } + + /** No answer, lost contact or a replaced runtime is waited out; a refusal is final. */ + private parkOrFail(error: Error): void { + if ( + !this.closed && + this.lastAuthority !== null && + (isBrowserHostLeaseContactLost(error) || + isBrowserClientHostAuthorityReplaced(error) || + isRecoverableBrowserHostLeaseError(error)) + ) { + this.park(error) + return + } + this.handleHostError(error) + } + + private clearGuestDiscard(): void { + if (this.parked?.discardTimer) { + clearTimeout(this.parked.discardTimer) + this.parked.discardTimer = null + } } private async handleCommand( @@ -234,7 +274,7 @@ export class PairedRuntimeBrowserClientHostComposition< private requestPageInventoryRefresh(): void { // A parked composition has no lease to refresh; its next attach carries a fresh inventory. - if (this.closed || this.parking.isParked || this.inventoryRefreshPromise) { + if (this.closed || this.parked || this.inventoryRefreshPromise) { return } const refresh = this.host.refreshPageInventory() diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts index 8eaf7fa4bcee..52dc67dbc55b 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts @@ -80,13 +80,12 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { expect(replacement.start).toHaveBeenCalledOnce() }) - it('asks a parked environment to re-attach when a start names its current runtime', async () => { + it('routes a start that names the current runtime through resume', async () => { const composition = createComposition() const registry = new PairedRuntimeBrowserClientHostRegistry({ createComposition: vi.fn(() => composition) }) await registry.start(input(11)) - composition.parked = true await expect(registry.start(input(11))).resolves.toEqual(authority) @@ -97,7 +96,7 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { it('keeps a parked environment registered when its re-attach fails', async () => { const composition = createComposition() composition.replaceAuthority.mockImplementationOnce(async () => { - composition.parked = true + composition.isParked = true throw new Error('remote runtime unavailable') }) const registry = new PairedRuntimeBrowserClientHostRegistry({ @@ -117,7 +116,7 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { ) }) - it('resumes only parked environments when a trigger fires', async () => { + it('re-attaches only the named environment, and only while it is parked', async () => { const parked = createComposition() const live = createComposition() const registry = new PairedRuntimeBrowserClientHostRegistry({ @@ -125,10 +124,11 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { }) await registry.start(input(11)) await registry.start({ ...input(11), environmentId: 'environment-b' }) - parked.parked = true + parked.isParked = true - await registry.resumeParked(() => true) - await registry.resumeParked((environmentId) => environmentId !== 'environment-a') + await registry.resume('environment-a') + await registry.resume('environment-b') + await registry.resume('environment-missing') expect(parked.resume).toHaveBeenCalledOnce() expect(live.resume).not.toHaveBeenCalled() @@ -223,7 +223,7 @@ function createComposition( closed = Promise.resolve() ) { return { - parked: false, + isParked: false, start: vi.fn(async () => { if (order && startLabel) { order.push(startLabel) diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.ts b/src/main/browser/paired-runtime-browser-client-host-registry.ts index 2ef3345281c1..ea9493d6b88b 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.ts @@ -10,7 +10,7 @@ type RegisteredBrowserClientHost = { start(): Promise replaceAuthority(input: Start): Promise /** True while the runtime is unreachable and the composition is holding its pages for it. */ - readonly parked: boolean + readonly isParked: boolean resume(): Promise retirePage(browserPageId: string, pageHostGeneration: number): Promise close(error?: Error): Promise @@ -57,10 +57,7 @@ export class PairedRuntimeBrowserClientHostRegistry< existing?.pairingRevision === input.pairingRevision && existing.authorityRuntimeId === input.authorityRuntimeId ) { - if (existing.composition.parked) { - existing.authority = existing.composition.resume() - } - return existing.authority + return (existing.authority = existing.composition.resume()) } if (existing?.pairingRevision === input.pairingRevision) { existing.authorityRuntimeId = input.authorityRuntimeId @@ -69,7 +66,7 @@ export class PairedRuntimeBrowserClientHostRegistry< return await existing.authority } catch (error) { // A parked composition still holds the pages for the runtime's return. - if (!existing.composition.parked) { + if (!existing.composition.isParked) { await this.cleanupFailedStart(input.environmentId, existing, error) } throw error @@ -125,20 +122,16 @@ export class PairedRuntimeBrowserClientHostRegistry< }) } - /** One re-attach attempt for each parked environment the caller allows; live ones are untouched. */ - resumeParked(allows: (environmentId: string) => boolean): Promise { - return Promise.all( - [...this.hosts.keys()].filter(allows).map((id) => - this.enqueue(id, async () => { - const record = this.hosts.get(id) - if (this.closed || !record?.composition.parked || record.cleanupPending) { - return - } - record.authority = record.composition.resume() - await record.authority.catch(() => undefined) - }) - ) - ).then(() => undefined) + /** One re-attach if the environment's host is parked; a live or absent one is untouched. */ + resume(environmentId: string): Promise { + return this.enqueue(environmentId, async () => { + const record = this.hosts.get(environmentId) + if (this.closed || !record?.composition.isParked || record.cleanupPending) { + return + } + record.authority = record.composition.resume() + await record.authority.catch(() => undefined) + }) } closeEnvironment(environmentId: string, error?: Error): Promise { diff --git a/src/main/browser/paired-runtime-browser-client-host-runtime.ts b/src/main/browser/paired-runtime-browser-client-host-runtime.ts index 5f7de4039986..2017f1fa4d70 100644 --- a/src/main/browser/paired-runtime-browser-client-host-runtime.ts +++ b/src/main/browser/paired-runtime-browser-client-host-runtime.ts @@ -87,11 +87,13 @@ const browserClientHosts = onClosing: routes.release, parkedGuestDiscardMs: e2eParkedGuestDiscardMs(), initialInput: input, - // Why park, not retire: a route that stayed dark is lost contact, not lost pages. + // Why park, not retire: a route that stayed dark is lost contact, not lost pages. The + // immediate resume keeps a healthy lease from sitting parked behind a live status. createRoutes: (next, authority) => createNetworkRoutes(next.pairing, authority, next.storageScope, (error) => { reportBrowserClientHostError(error) composition.park(error) + void browserClientHosts.resume(input.environmentId) }), createExecutor: (next, { retainNetworkRoute, onPageUnavailable }) => { executor = new BrowserClientPageCommandExecutor({ @@ -236,13 +238,6 @@ export function retirePairedRuntimeBrowserClientHostEnvironment( return browserClientHosts.retireEnvironment(environmentId, error) } -/** Asks each parked environment the caller allows for a single re-attach. */ -export function resumeParkedPairedRuntimeBrowserClientHosts( - allows: (environmentId: string) => boolean -): Promise { - return browserClientHosts.resumeParked(allows) -} - export function shutdownPairedRuntimeBrowserClientHosts(): Promise { clientHostRouteIdentities.clear() return browserClientHosts.close() diff --git a/src/main/browser/paired-runtime-browser-client-host-teardown.ts b/src/main/browser/paired-runtime-browser-client-host-teardown.ts index 93cbc95ab3c2..ac50151f0d0d 100644 --- a/src/main/browser/paired-runtime-browser-client-host-teardown.ts +++ b/src/main/browser/paired-runtime-browser-client-host-teardown.ts @@ -66,3 +66,43 @@ export async function closeBrowserClientHostComposition( export function asCompositionError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } + +/** How long a parked desktop keeps its live guests before freeing them; the tabs stay either way. */ +const PARKED_GUEST_DISCARD_MS = 60 * 60 * 1000 + +type ReleasableGuests = { + snapshotPageInventory(): readonly { browserPageId: string; pageHostGeneration: number }[] + retirePage(browserPageId: string, pageHostGeneration: number): Promise +} + +/** Frees memory after a long absence only; it decides nothing about whether a page exists. */ +export function scheduleParkedGuestDiscard( + executor: ReleasableGuests, + discardMs = PARKED_GUEST_DISCARD_MS +): ReturnType { + const timer = setTimeout( + () => void releaseBrowserClientGuests(executor).catch(warnParkedCleanup), + discardMs + ) + timer.unref?.() + return timer +} + +/** Frees every live guest; the runtime recreates each page at its last URL when it can. */ +export async function releaseBrowserClientGuests(executor: ReleasableGuests): Promise { + const results = await Promise.allSettled( + executor + .snapshotPageInventory() + .map((page) => executor.retirePage(page.browserPageId, page.pageHostGeneration)) + ) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length > 0) { + throw new AggregateError(failures, 'Browser client guest release failed') + } +} + +export function warnParkedCleanup(error: unknown): void { + console.warn('[browser-client-host] parked cleanup failed:', asCompositionError(error).message) +} diff --git a/src/main/ipc/runtime-environment-recovery-handler.ts b/src/main/ipc/runtime-environment-recovery-handler.ts index 9e6e8afb1a84..7477956f9edd 100644 --- a/src/main/ipc/runtime-environment-recovery-handler.ts +++ b/src/main/ipc/runtime-environment-recovery-handler.ts @@ -1,7 +1,5 @@ import { ipcMain } from 'electron' import { retryRemoteRuntimeSharedControlConnectionsNow } from './runtime-environment-request-connections' -import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-manual-disconnect' -import { resumeParkedPairedRuntimeBrowserClientHosts } from '../browser/paired-runtime-browser-client-host-runtime' const RETRY_CONNECTIONS_NOW_CHANNEL = 'runtimeEnvironments:retryConnectionsNow' @@ -9,9 +7,5 @@ export function registerRuntimeEnvironmentRecoveryHandler(): void { ipcMain.removeHandler(RETRY_CONNECTIONS_NOW_CHANNEL) ipcMain.handle(RETRY_CONNECTIONS_NOW_CHANNEL, () => { retryRemoteRuntimeSharedControlConnectionsNow() - // Online, wake, and the user opening or retrying a client-hosted tab all land here. - void resumeParkedPairedRuntimeBrowserClientHosts( - (environmentId) => !isRuntimeEnvironmentManuallyDisconnected(environmentId) - ) }) } diff --git a/src/main/runtime/browser-host-client-page-adoption.ts b/src/main/runtime/browser-host-client-page-adoption.ts index a05803fc49b3..848fb067477f 100644 --- a/src/main/runtime/browser-host-client-page-adoption.ts +++ b/src/main/runtime/browser-host-client-page-adoption.ts @@ -3,6 +3,7 @@ import type { BrowserClientPageExecutionHostGrant } from './browser-host-client- import type { BrowserHostLeaseState } from './browser-host-lease-records' import type { RuntimeBrowserPlacement } from './browser-host-page-placement' import type { BrowserHostRuntimePageIntent } from './browser-host-page-reconciliation-plan' +import type { BrowserHostPageAdoptionOptions } from './browser-host-page-reconciliation-orchestration' /** * An inventory entry a restarted runtime may take back over. `workspaceId` is narrowed to present @@ -105,20 +106,13 @@ export function buildClientPageAdoptionIntents(input: { }) } -type ReconciliationOptions = { - maxConcurrency?: number - actionTimeoutMs?: number - signal?: AbortSignal -} - export type BrowserHostClientPageAdoptionDependencies = { state: BrowserHostLeaseState reconciliations: { adopt( state: BrowserHostLeaseState, intents: readonly BrowserHostRuntimePageIntent[], - options: ReconciliationOptions, - releasedPageIds: readonly string[] + options: BrowserHostPageAdoptionOptions ): Promise } placements: { getPlacement(browserPageId: string): RuntimeBrowserPlacement | undefined } @@ -135,11 +129,10 @@ export type BrowserHostClientPageAdoptionDependencies = { */ export async function adoptBrowserHostClientPages( intents: readonly BrowserHostRuntimePageIntent[], - options: ReconciliationOptions, - dependencies: BrowserHostClientPageAdoptionDependencies, - releasedPageIds: readonly string[] = [] + options: BrowserHostPageAdoptionOptions, + dependencies: BrowserHostClientPageAdoptionDependencies ): Promise { - if (intents.length === 0 && releasedPageIds.length === 0) { + if (intents.length === 0 && !options.releasedPageIds?.length) { return [] } const grants = intents.map((intent) => ({ @@ -147,7 +140,7 @@ export async function adoptBrowserHostClientPages( grant: dependencies.state.executionHostGrants.retain(intent.executionHostKey) })) await dependencies.reconciliations - .adopt(dependencies.state, intents, options, releasedPageIds) + .adopt(dependencies.state, intents, options) .catch(() => undefined) const adopted: string[] = [] for (const { intent, grant } of grants) { diff --git a/src/main/runtime/browser-host-lease-registry.ts b/src/main/runtime/browser-host-lease-registry.ts index 59e306734d87..49964facbf50 100644 --- a/src/main/runtime/browser-host-lease-registry.ts +++ b/src/main/runtime/browser-host-lease-registry.ts @@ -37,7 +37,10 @@ import { } from './browser-host-client-page-creation' import { adoptBrowserHostClientPages } from './browser-host-client-page-adoption' import { snapshotBrowserHostPageInventory } from './browser-host-page-inventory-snapshot' -import { BrowserHostPageReconciliationOrchestrator } from './browser-host-page-reconciliation-orchestration' +import { + BrowserHostPageReconciliationOrchestrator, + type BrowserHostPageAdoptionOptions +} from './browser-host-page-reconciliation-orchestration' import type { BrowserHostRuntimePageIntent } from './browser-host-page-reconciliation-plan' import { BrowserHostLeaseReconnectController } from './browser-host-lease-reconnect' import { @@ -224,20 +227,14 @@ export class BrowserHostLeaseRegistry { adoptClientPages( identity: BrowserHostLeaseIdentity, intents: readonly BrowserHostRuntimePageIntent[], - options: { maxConcurrency?: number; actionTimeoutMs?: number; signal?: AbortSignal } = {}, - releasedPageIds: readonly string[] = [] + options: BrowserHostPageAdoptionOptions = {} ): Promise { - return adoptBrowserHostClientPages( - intents, - options, - { - state: this.requireLeaseState(identity), - reconciliations: this.pageReconciliations, - placements: this.pagePlacements, - executionHostGrants: this.clientPageExecutionHostGrants - }, - releasedPageIds - ) + return adoptBrowserHostClientPages(intents, options, { + state: this.requireLeaseState(identity), + reconciliations: this.pageReconciliations, + placements: this.pagePlacements, + executionHostGrants: this.clientPageExecutionHostGrants + }) } /** The lowest page generation a new placement may still take. */ diff --git a/src/main/runtime/browser-host-page-reconciliation-orchestration.ts b/src/main/runtime/browser-host-page-reconciliation-orchestration.ts index aa370ec128b3..2771d08ebf99 100644 --- a/src/main/runtime/browser-host-page-reconciliation-orchestration.ts +++ b/src/main/runtime/browser-host-page-reconciliation-orchestration.ts @@ -20,6 +20,11 @@ export type BrowserHostPageReconciliationOptions = { signal?: AbortSignal } +export type BrowserHostPageAdoptionOptions = BrowserHostPageReconciliationOptions & { + /** Entries naming this runtime whose pages it has since released: the one orphan it can name. */ + releasedPageIds?: readonly string[] +} + export class BrowserHostPageReconciliationOrchestrator { private readonly attempts = new Map() private readonly consumedInventories = new WeakSet() @@ -44,9 +49,7 @@ export class BrowserHostPageReconciliationOrchestrator { adopt( state: BrowserHostLeaseState, intents: readonly BrowserHostRuntimePageIntent[], - options: BrowserHostPageReconciliationOptions = {}, - /** Entries naming this runtime whose pages it has since released: the one orphan it can name. */ - releasedPageIds: readonly string[] = [] + { releasedPageIds = [], ...options }: BrowserHostPageAdoptionOptions = {} ): Promise { const claimed = new Set([...intents.map((intent) => intent.browserPageId), ...releasedPageIds]) return this.run(state, intents, options, (inventory) => diff --git a/src/main/runtime/runtime-browser-client-page-adoption.ts b/src/main/runtime/runtime-browser-client-page-adoption.ts index 7d722e540408..c9156f2efd7b 100644 --- a/src/main/runtime/runtime-browser-client-page-adoption.ts +++ b/src/main/runtime/runtime-browser-client-page-adoption.ts @@ -160,8 +160,10 @@ export async function adoptRuntimeBrowserClientPagesFromInventory( pairedDeviceId: options.lease.pairedDeviceId }, [...intents, ...returningIntents], - options.signal ? { signal: options.signal } : {}, - returning.releasedPageIds + { + ...(options.signal ? { signal: options.signal } : {}), + releasedPageIds: returning.releasedPageIds + } ) ) const reclaimedPageIds = republishReturningClientPages(options, returning, adoptedPageIds) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx index 121e7fe20f0b..9da9f314d640 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx @@ -3,10 +3,12 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { resetBrowserClientHostIdForTests } from '@/runtime/browser-client-host-identity' +import { resetRestoredBrowserClientHostAttachForTests } from '@/runtime/restored-client-hosted-browser-host-attach' import { installClientHostedPaneApi } from './client-hosted-browser-pane-test-rig' import { ClientHostedBrowserAvailabilityNotice } from './client-hosted-browser-unavailable-notice' -const retryConnectionsNow = vi.fn(async () => {}) +const retryControlConnection = vi.fn(async () => {}) +const prepareBrowserClientHostPlacement = vi.fn(async () => ({ kind: 'client' })) function renderNotice( props: Partial[0]> = {} @@ -24,9 +26,14 @@ function renderNotice( ) } +const clientHostedPage = { environmentId: 'env-a', placement: { kind: 'client' } } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reads only environmentId and placement. +const CLIENT_HOSTED_PAGE_HANDLES = { 'page-a': clientHostedPage } as never + function setHostOffline(offline: boolean): void { useAppStore.setState({ runtimeStatusByEnvironmentId: offline ? new Map([['env-a', { status: null }]]) : new Map(), + remoteBrowserPageHandlesByPageId: CLIENT_HOSTED_PAGE_HANDLES, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the notice reads only id and name. runtimeEnvironments: [{ id: 'env-a', name: 'Build box' }] as never }) @@ -35,10 +42,12 @@ function setHostOffline(offline: boolean): void { describe('ClientHostedBrowserAvailabilityNotice', () => { beforeEach(() => { resetBrowserClientHostIdForTests() - retryConnectionsNow.mockClear() + resetRestoredBrowserClientHostAttachForTests() + retryControlConnection.mockClear() + prepareBrowserClientHostPlacement.mockClear() installClientHostedPaneApi({ browser: { readClientHostId: () => 'this-desktop' }, - runtimeEnvironments: { retryConnectionsNow } + runtimeEnvironments: { retryControlConnection, prepareBrowserClientHostPlacement } }) }) afterEach(() => { @@ -53,13 +62,17 @@ describe('ClientHostedBrowserAvailabilityNotice', () => { expect(container.innerHTML).toBe('') }) - it('marks a live page whose host is offline, and a click asks for a re-attach', () => { + it('marks a live page whose host is offline, and a click retries that host and its browser host', () => { setHostOffline(true) renderNotice() expect(screen.getByRole('status').textContent).toContain('Build box is offline') fireEvent.click(screen.getByRole('button', { name: 'Reconnect now' })) - expect(retryConnectionsNow).toHaveBeenCalled() + expect(retryControlConnection).toHaveBeenCalledWith({ selector: 'env-a' }) + expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ + selector: 'env-a', + preference: 'auto' + }) }) it('waits for an offline host instead of calling a missing guest unavailable', () => { @@ -80,9 +93,14 @@ describe('ClientHostedBrowserAvailabilityNotice', () => { expect(screen.queryByText(/different desktop/)).toBeNull() }) - it('asks a parked host to re-attach when the tab is opened', () => { + it('asks only its own environment to re-attach when the tab is opened', () => { + setHostOffline(false) renderNotice({ isActive: true }) - expect(retryConnectionsNow).toHaveBeenCalled() + expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ + selector: 'env-a', + preference: 'auto' + }) + expect(retryControlConnection).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx index b7c2dacdc480..81e12e1bbb84 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx @@ -7,7 +7,7 @@ import { } from './ReopenBrowserPageOnServerButton' import { readBrowserClientHostId } from '@/runtime/browser-client-host-identity' import { - requestClientHostedBrowserReattach, + requestClientHostedBrowserReconnect, useClientHostedBrowserHostReachability } from './use-client-hosted-browser-host-reachability' @@ -51,7 +51,12 @@ export function ClientHostedBrowserAvailabilityNotice({ /> ) } - return hostOffline ? : null + return hostOffline ? ( + + ) : null } /** @@ -88,7 +93,7 @@ export function ClientHostedBrowserUnavailableNotice({ type="button" variant="outline" size="sm" - onClick={() => requestClientHostedBrowserReattach()} + onClick={() => requestClientHostedBrowserReconnect(runtimeEnvironmentId)} > {translate('browser.clientHosted.reconnectNow', 'Reconnect now')} @@ -127,8 +132,10 @@ export function ClientHostedBrowserUnavailableNotice({ /** Sits over a live page while its host is unreachable: the page stays, its network does not. */ function ClientHostedBrowserHostOfflineStrip({ + runtimeEnvironmentId, hostName }: { + runtimeEnvironmentId: string hostName: string | null }): React.JSX.Element { return ( @@ -146,7 +153,7 @@ function ClientHostedBrowserHostOfflineStrip({ type="button" variant="ghost" size="xs" - onClick={() => requestClientHostedBrowserReattach()} + onClick={() => requestClientHostedBrowserReconnect(runtimeEnvironmentId)} > {translate('browser.clientHosted.reconnectNow', 'Reconnect now')} diff --git a/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts index 20be9d4a2239..cbe9f66c8393 100644 --- a/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts +++ b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts @@ -1,10 +1,14 @@ import { useEffect } from 'react' import { useAppStore } from '@/store' import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' +import { ensureBrowserClientHostForReturningRuntime } from '@/runtime/restored-client-hosted-browser-host-attach' -/** Asks main for one re-attach of every parked browser host; a no-op for attached ones. */ -export function requestClientHostedBrowserReattach(): void { - void window.api.runtimeEnvironments.retryConnectionsNow?.().catch(() => undefined) +/** Retries the host connection now, and re-attaches this environment's browser host if parked. */ +export function requestClientHostedBrowserReconnect(runtimeEnvironmentId: string): void { + void window.api.runtimeEnvironments + .retryControlConnection?.({ selector: runtimeEnvironmentId }) + .catch(() => undefined) + void ensureBrowserClientHostForReturningRuntime(useAppStore.getState(), runtimeEnvironmentId) } /** @@ -31,7 +35,7 @@ export function useClientHostedBrowserHostReachability({ ) useEffect(() => { if (isActive) { - requestClientHostedBrowserReattach() + void ensureBrowserClientHostForReturningRuntime(useAppStore.getState(), runtimeEnvironmentId) } }, [isActive, runtimeEnvironmentId]) return { hostOffline, hostName } diff --git a/src/renderer/src/store/slices/runtime-status.ts b/src/renderer/src/store/slices/runtime-status.ts index 92abe8ce01a2..8197dc8b88ae 100644 --- a/src/renderer/src/store/slices/runtime-status.ts +++ b/src/renderer/src/store/slices/runtime-status.ts @@ -181,6 +181,7 @@ export const createRuntimeStatusSlice: StateCreator Date: Tue, 6 Oct 2026 02:21:07 -0400 Subject: [PATCH 09/12] fix(remote-browser): close a parked browser host only on the runtime's own refusal Unknown, local and transport errors now park instead of closing, so an unclassified failure is never read as the pages being gone. The lease marks the RPC failures the host actually sent; only a marked, non-recoverable, non-capacity answer (or the reconciliation-unsupported verdict read from the host's ready answer) closes the composition. --- .../browser-host-admission-recovery.ts | 14 +++++++ ...me-browser-client-host-composition.test.ts | 37 +++++++++++++++++-- ...runtime-browser-client-host-composition.ts | 12 +++--- ...d-runtime-browser-host-lease-connection.ts | 5 ++- 4 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/main/browser/browser-host-admission-recovery.ts b/src/main/browser/browser-host-admission-recovery.ts index baf75f868187..c2c98071fc81 100644 --- a/src/main/browser/browser-host-admission-recovery.ts +++ b/src/main/browser/browser-host-admission-recovery.ts @@ -72,6 +72,20 @@ export function isRecoverableBrowserHostLeaseError(error: unknown): boolean { ) } +// Why a set: a host-sent RPC failure and a locally built one share RemoteRuntimeClientError. +const browserHostAnswers = new WeakSet() + +/** Marks an error the runtime itself answered with, as opposed to a transport or local failure. */ +export function browserHostAnswer(error: T): T { + browserHostAnswers.add(error) + return error +} + +/** Only the runtime's own non-recoverable answer is a refusal; anything else may be lost contact. */ +export function isBrowserHostRefusal(error: Error): boolean { + return browserHostAnswers.has(error) && !isRecoverableBrowserHostLeaseError(error) +} + function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts index 8b2e7f70272f..0751c3a1dfcb 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts @@ -5,6 +5,8 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import { BrowserHostLeaseContactLostError } from './browser-host-lease-contact-loss' +import { browserHostAnswer } from './browser-host-admission-recovery' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browser-client-host-protocol' import { PairedRuntimeBrowserClientHostComposition } from './paired-runtime-browser-client-host-composition' @@ -267,7 +269,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { it('fences navigation and routes before terminal host cleanup can wait', async () => { const rig = createRig() const composition = rig.createComposition() - const error = new Error('terminal authority loss') + const error = hostRefusal() await composition.start() rig.hostOptions.onError?.(error) @@ -319,11 +321,11 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { } }) - it('still tears down immediately for a host error that is not lost contact', async () => { + it('tears down immediately for a refusal the runtime itself answered with', async () => { const rig = createRig() const composition = rig.createComposition() await composition.start() - const fatal = new Error('Stale browser host page command') + const fatal = hostRefusal() rig.hostOptions.onError?.(fatal) @@ -332,6 +334,29 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { await composition.whenClosed() }) + it.each([ + ['an unknown local error', () => new Error('Stale browser host page command')], + [ + 'a host-sent recoverable code', + () => browserHostAnswer(new RemoteRuntimeClientError('runtime_unavailable', 'restarting')) + ], + [ + 'a host-sent capacity code', + () => browserHostAnswer(new RemoteRuntimeClientError('runtime_busy', 'lease capacity')) + ] + ])('parks on %s instead of treating it as gone', async (_label, error) => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + + rig.hostOptions.onError?.(error()) + + expect(composition.isParked).toBe(true) + expect(rig.onError).not.toHaveBeenCalled() + expect(rig.executor.close).not.toHaveBeenCalled() + await composition.close() + }) + it('re-attaches a parked composition in place when the runtime can rekey kept guests', async () => { const rig = createRig({ initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } @@ -451,7 +476,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { const composition = rig.createComposition() await composition.start() rig.hostOptions.onError?.(contactLostError()) - const refusal = Object.assign(new Error('pairing revoked'), { code: 'unauthorized' }) + const refusal = hostRefusal() rig.failNextStart(refusal) await expect(composition.resume()).rejects.toThrow('pairing revoked') @@ -785,6 +810,10 @@ function contactLostError(): Error { ) } +function hostRefusal(): Error { + return browserHostAnswer(new RemoteRuntimeClientError('unauthorized', 'pairing revoked')) +} + function unreachableError(): Error { return Object.assign(new Error('remote runtime unavailable'), { code: 'remote_runtime_unavailable' diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.ts b/src/main/browser/paired-runtime-browser-client-host-composition.ts index d93f082af6ec..83bfde74048e 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.ts @@ -5,8 +5,7 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' -import { isRecoverableBrowserHostLeaseError } from './browser-host-admission-recovery' -import { isBrowserHostLeaseContactLost } from './browser-host-lease-contact-loss' +import { browserHostAnswer, isBrowserHostRefusal } from './browser-host-admission-recovery' import { asCompositionError, closeBrowserClientHostComposition, @@ -169,7 +168,8 @@ export class PairedRuntimeBrowserClientHostComposition< publishedInventory?.length && authority.pageReconciliationProtocolVersion !== 1 ) { - throw new Error('browser_client_page_reconciliation_unsupported') + // Read from the runtime's own ready answer, so it is as final as a refusal. + throw browserHostAnswer(new Error('browser_client_page_reconciliation_unsupported')) } this.routeSets.activate(input, authority) this.lastAuthority = authority @@ -235,14 +235,12 @@ export class PairedRuntimeBrowserClientHostComposition< } } - /** No answer, lost contact or a replaced runtime is waited out; a refusal is final. */ + /** Only the runtime's own refusal is final; anything else may be lost contact and is waited out. */ private parkOrFail(error: Error): void { if ( !this.closed && this.lastAuthority !== null && - (isBrowserHostLeaseContactLost(error) || - isBrowserClientHostAuthorityReplaced(error) || - isRecoverableBrowserHostLeaseError(error)) + (!isBrowserHostRefusal(error) || isBrowserClientHostAuthorityReplaced(error)) ) { this.park(error) return diff --git a/src/main/browser/paired-runtime-browser-host-lease-connection.ts b/src/main/browser/paired-runtime-browser-host-lease-connection.ts index e8d64d78a92d..9b624add5d24 100644 --- a/src/main/browser/paired-runtime-browser-host-lease-connection.ts +++ b/src/main/browser/paired-runtime-browser-host-lease-connection.ts @@ -13,6 +13,7 @@ import { type RemoteRuntimeSubscriptionCallbacks } from '../../shared/remote-runtime-client' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { browserHostAnswer } from './browser-host-admission-recovery' import { createBrowserClientHostAttachRequest } from './browser-client-host-attach-request' import { sameBrowserClientHostLeaseAuthority } from './browser-client-host-command-authority' import type { PairedRuntimeBrowserHostLeaseOptions } from './paired-runtime-browser-host-lease-options' @@ -155,7 +156,9 @@ export class PairedRuntimeBrowserHostLeaseConnection { return } if (!response.ok) { - this.fail(new RemoteRuntimeClientError(response.error.code, response.error.message)) + this.fail( + browserHostAnswer(new RemoteRuntimeClientError(response.error.code, response.error.message)) + ) return } const parsed = BrowserClientHostEvent.safeParse(response.result) From e665fc5d98c3eaab0307091d3732053d5bda825f Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 02:22:34 -0400 Subject: [PATCH 10/12] refactor(remote-browser): mark host-sent lease failures with an error type, not a side table BrowserHostAnswerError subclasses RemoteRuntimeClientError, so every existing code/instanceof consumer is unchanged; parkOrFail checks the type directly. --- .../browser/browser-host-admission-recovery.ts | 15 +++++++-------- ...untime-browser-client-host-composition.test.ts | 9 ++++----- ...red-runtime-browser-client-host-composition.ts | 7 +++++-- ...aired-runtime-browser-host-lease-connection.ts | 4 ++-- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/main/browser/browser-host-admission-recovery.ts b/src/main/browser/browser-host-admission-recovery.ts index c2c98071fc81..c2544539176c 100644 --- a/src/main/browser/browser-host-admission-recovery.ts +++ b/src/main/browser/browser-host-admission-recovery.ts @@ -72,18 +72,17 @@ export function isRecoverableBrowserHostLeaseError(error: unknown): boolean { ) } -// Why a set: a host-sent RPC failure and a locally built one share RemoteRuntimeClientError. -const browserHostAnswers = new WeakSet() - -/** Marks an error the runtime itself answered with, as opposed to a transport or local failure. */ -export function browserHostAnswer(error: T): T { - browserHostAnswers.add(error) - return error +/** A failure the runtime itself answered with, as opposed to a transport or local one. */ +export class BrowserHostAnswerError extends RemoteRuntimeClientError { + constructor(code: string, message: string, data?: unknown) { + super(code, message, { data }) + this.name = 'BrowserHostAnswerError' + } } /** Only the runtime's own non-recoverable answer is a refusal; anything else may be lost contact. */ export function isBrowserHostRefusal(error: Error): boolean { - return browserHostAnswers.has(error) && !isRecoverableBrowserHostLeaseError(error) + return error instanceof BrowserHostAnswerError && !isRecoverableBrowserHostLeaseError(error) } function asError(error: unknown): Error { diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts index 0751c3a1dfcb..cecec24459ac 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts @@ -5,8 +5,7 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import { BrowserHostLeaseContactLostError } from './browser-host-lease-contact-loss' -import { browserHostAnswer } from './browser-host-admission-recovery' -import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { BrowserHostAnswerError } from './browser-host-admission-recovery' import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browser-client-host-protocol' import { PairedRuntimeBrowserClientHostComposition } from './paired-runtime-browser-client-host-composition' @@ -338,11 +337,11 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { ['an unknown local error', () => new Error('Stale browser host page command')], [ 'a host-sent recoverable code', - () => browserHostAnswer(new RemoteRuntimeClientError('runtime_unavailable', 'restarting')) + () => new BrowserHostAnswerError('runtime_unavailable', 'restarting') ], [ 'a host-sent capacity code', - () => browserHostAnswer(new RemoteRuntimeClientError('runtime_busy', 'lease capacity')) + () => new BrowserHostAnswerError('runtime_busy', 'lease capacity') ] ])('parks on %s instead of treating it as gone', async (_label, error) => { const rig = createRig() @@ -811,7 +810,7 @@ function contactLostError(): Error { } function hostRefusal(): Error { - return browserHostAnswer(new RemoteRuntimeClientError('unauthorized', 'pairing revoked')) + return new BrowserHostAnswerError('unauthorized', 'pairing revoked') } function unreachableError(): Error { diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.ts b/src/main/browser/paired-runtime-browser-client-host-composition.ts index 83bfde74048e..2c6087b1c314 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.ts @@ -5,7 +5,7 @@ import type { BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' -import { browserHostAnswer, isBrowserHostRefusal } from './browser-host-admission-recovery' +import { BrowserHostAnswerError, isBrowserHostRefusal } from './browser-host-admission-recovery' import { asCompositionError, closeBrowserClientHostComposition, @@ -169,7 +169,10 @@ export class PairedRuntimeBrowserClientHostComposition< authority.pageReconciliationProtocolVersion !== 1 ) { // Read from the runtime's own ready answer, so it is as final as a refusal. - throw browserHostAnswer(new Error('browser_client_page_reconciliation_unsupported')) + throw new BrowserHostAnswerError( + 'browser_client_page_reconciliation_unsupported', + 'browser_client_page_reconciliation_unsupported' + ) } this.routeSets.activate(input, authority) this.lastAuthority = authority diff --git a/src/main/browser/paired-runtime-browser-host-lease-connection.ts b/src/main/browser/paired-runtime-browser-host-lease-connection.ts index 9b624add5d24..2f64cbea3939 100644 --- a/src/main/browser/paired-runtime-browser-host-lease-connection.ts +++ b/src/main/browser/paired-runtime-browser-host-lease-connection.ts @@ -13,7 +13,7 @@ import { type RemoteRuntimeSubscriptionCallbacks } from '../../shared/remote-runtime-client' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' -import { browserHostAnswer } from './browser-host-admission-recovery' +import { BrowserHostAnswerError } from './browser-host-admission-recovery' import { createBrowserClientHostAttachRequest } from './browser-client-host-attach-request' import { sameBrowserClientHostLeaseAuthority } from './browser-client-host-command-authority' import type { PairedRuntimeBrowserHostLeaseOptions } from './paired-runtime-browser-host-lease-options' @@ -157,7 +157,7 @@ export class PairedRuntimeBrowserHostLeaseConnection { } if (!response.ok) { this.fail( - browserHostAnswer(new RemoteRuntimeClientError(response.error.code, response.error.message)) + new BrowserHostAnswerError(response.error.code, response.error.message, response.error.data) ) return } From fa0943b7c02e7f2271c1c4f27f05d48eca6ddcad Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 02:43:42 -0400 Subject: [PATCH 11/12] fix(remote-browser): round-2 review of browser host parking - Free only the guest generations published in the attach inventory, so a page the older runtime's recovery already placed survives the release. - A dark route swaps the lease through one queued registry re-attach instead of parking and kicking a resume. - Main publishes each environment's parked state; the offline strip shows whenever the browser host is parked, even with the control link up. - Tab activation and Reconnect use a thin resume IPC instead of a status round-trip through placement preparation. - One unpark() clears the discard timer, and a re-attach superseded by a new park during its await does not unpark. - isFinalBrowserHostRefusal is the one predicate; reconciliation_unsupported is built by browserHostRefusal(); lastAuthority becomes attached. --- .../browser-client-host-parked-publication.ts | 39 ++++++++ .../browser-host-admission-recovery.test.ts | 34 +++++++ .../browser-host-admission-recovery.ts | 19 +++- ...rowser-client-host-composition-contract.ts | 2 + ...me-browser-client-host-composition.test.ts | 96 ++++++++++++++----- ...runtime-browser-client-host-composition.ts | 96 ++++++++++--------- ...ntime-browser-client-host-registry.test.ts | 9 +- ...ed-runtime-browser-client-host-registry.ts | 17 +++- ...red-runtime-browser-client-host-runtime.ts | 15 ++- ...ed-runtime-browser-client-host-teardown.ts | 9 +- ...environment-browser-client-host-handler.ts | 13 +++ .../runtime-environment-handler-channels.ts | 1 + .../ipc/runtime-environments-pairing.test.ts | 2 + src/preload/api/runtime-api.ts | 6 ++ .../api/runtime-environments-bridge.ts | 16 ++++ ...hosted-browser-unavailable-notice.test.tsx | 40 ++++---- ...client-hosted-browser-host-reachability.ts | 16 +++- ...browser-client-host-parked-environments.ts | 50 ++++++++++ src/shared/browser-client-host-parked.ts | 4 + 19 files changed, 376 insertions(+), 108 deletions(-) create mode 100644 src/main/browser/browser-client-host-parked-publication.ts create mode 100644 src/main/browser/browser-host-admission-recovery.test.ts create mode 100644 src/renderer/src/runtime/browser-client-host-parked-environments.ts create mode 100644 src/shared/browser-client-host-parked.ts diff --git a/src/main/browser/browser-client-host-parked-publication.ts b/src/main/browser/browser-client-host-parked-publication.ts new file mode 100644 index 000000000000..c2af11bb32f6 --- /dev/null +++ b/src/main/browser/browser-client-host-parked-publication.ts @@ -0,0 +1,39 @@ +import { BrowserWindow } from 'electron' +import { + BROWSER_CLIENT_HOST_PARKED_CHANNEL, + type BrowserClientHostParkedEvent +} from '../../shared/browser-client-host-parked' + +const parkedEnvironmentIds = new Set() + +export function isBrowserClientHostEnvironmentParked(environmentId: string): boolean { + return parkedEnvironmentIds.has(environmentId) +} + +/** Tells every renderer, so the offline strip follows the browser host and not the control link. */ +export function publishBrowserClientHostParked( + environmentId: string, + parked: boolean, + error?: Error +): void { + if (parked) { + parkedEnvironmentIds.add(environmentId) + console.warn('[browser-client-host] runtime unreachable; keeping pages until it returns:', { + environmentId, + error: error?.message + }) + } else { + parkedEnvironmentIds.delete(environmentId) + } + const event: BrowserClientHostParkedEvent = { environmentId, parked } + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) { + continue + } + try { + window.webContents.send(BROWSER_CLIENT_HOST_PARKED_CHANNEL, event) + } catch { + /* A renderer can close during publication. */ + } + } +} diff --git a/src/main/browser/browser-host-admission-recovery.test.ts b/src/main/browser/browser-host-admission-recovery.test.ts new file mode 100644 index 000000000000..eeb77d201881 --- /dev/null +++ b/src/main/browser/browser-host-admission-recovery.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browser-client-host-protocol' +import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +import { + BrowserHostAnswerError, + browserHostRefusal, + isFinalBrowserHostRefusal +} from './browser-host-admission-recovery' + +describe('isFinalBrowserHostRefusal', () => { + it('is final only for a non-recoverable answer the runtime itself gave', () => { + expect(isFinalBrowserHostRefusal(new BrowserHostAnswerError('unauthorized', 'revoked'))).toBe( + true + ) + expect( + isFinalBrowserHostRefusal( + browserHostRefusal('browser_client_page_reconciliation_unsupported', 'cannot reconcile') + ) + ).toBe(true) + }) + + it.each([ + ['an unknown local error', new Error('Stale browser host page command')], + ['a locally built client error', new RemoteRuntimeClientError('unauthorized', 'local')], + ['a host-sent recoverable code', new BrowserHostAnswerError('runtime_unavailable', 'restart')], + ['a host-sent capacity code', new BrowserHostAnswerError('runtime_busy', 'lease capacity')], + [ + 'a replaced runtime', + new BrowserHostAnswerError(BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE, 'replaced') + ] + ])('is not final for %s', (_label, error) => { + expect(isFinalBrowserHostRefusal(error)).toBe(false) + }) +}) diff --git a/src/main/browser/browser-host-admission-recovery.ts b/src/main/browser/browser-host-admission-recovery.ts index c2544539176c..de0d3252672f 100644 --- a/src/main/browser/browser-host-admission-recovery.ts +++ b/src/main/browser/browser-host-admission-recovery.ts @@ -4,6 +4,7 @@ import { isRecoverableRemoteRuntimeConnectionError, toRemoteRuntimeClientErrorLike } from '../../shared/remote-runtime-client-error-classification' +import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' import { nextBrowserHostReconnectDelay } from './browser-host-lease-reconnect-delay' import type { BrowserHostReconnectDelay } from './browser-host-lease-reconnect-delay' @@ -80,9 +81,21 @@ export class BrowserHostAnswerError extends RemoteRuntimeClientError { } } -/** Only the runtime's own non-recoverable answer is a refusal; anything else may be lost contact. */ -export function isBrowserHostRefusal(error: Error): boolean { - return error instanceof BrowserHostAnswerError && !isRecoverableBrowserHostLeaseError(error) +/** A refusal read from the runtime's own answer rather than from its RPC failure frame. */ +export function browserHostRefusal(code: string, message: string): BrowserHostAnswerError { + return new BrowserHostAnswerError(code, message) +} + +/** + * Only the runtime's own non-recoverable answer is final. A replaced runtime still answers, but its + * successor takes the pages back; anything else may be lost contact. + */ +export function isFinalBrowserHostRefusal(error: Error): boolean { + return ( + error instanceof BrowserHostAnswerError && + !isRecoverableBrowserHostLeaseError(error) && + !isBrowserClientHostAuthorityReplaced(error) + ) } function asError(error: unknown): Error { diff --git a/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts b/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts index 149a16db5021..4bd0687daad6 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts @@ -68,4 +68,6 @@ export type PairedRuntimeBrowserClientHostCompositionOptions< /** Runs as closing begins, before teardown: the point after which this composition owns nothing. */ onClosing?: () => void parkedGuestDiscardMs?: number + /** Parked means the runtime is out of reach and the pages are held for its return. */ + onParkedChange?: (parked: boolean, error?: Error) => void } diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts index cecec24459ac..7f57e058a81d 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts @@ -14,7 +14,8 @@ const authority: BrowserClientHostLeaseAuthority = { authorityEpoch: 'epoch-a', browserHostClientId: 'client-a', browserHostGeneration: 4, - pageCommandProtocolVersion: 1 + pageCommandProtocolVersion: 1, + pageReconciliationProtocolVersion: 1 } const replacementAuthority: BrowserClientHostLeaseAuthority = { @@ -120,9 +121,9 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { const composition = rig.createComposition() await composition.start() - await expect(composition.replaceAuthority(replacementInput)).rejects.toThrow( - 'browser_client_page_reconciliation_unsupported' - ) + await expect(composition.replaceAuthority(replacementInput)).rejects.toMatchObject({ + code: 'browser_client_page_reconciliation_unsupported' + }) expect(rig.replacementInventory).toEqual([ expect.objectContaining({ browserPageId: 'page-a', authorityRuntimeId: 'runtime-a' }) @@ -333,22 +334,12 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { await composition.whenClosed() }) - it.each([ - ['an unknown local error', () => new Error('Stale browser host page command')], - [ - 'a host-sent recoverable code', - () => new BrowserHostAnswerError('runtime_unavailable', 'restarting') - ], - [ - 'a host-sent capacity code', - () => new BrowserHostAnswerError('runtime_busy', 'lease capacity') - ] - ])('parks on %s instead of treating it as gone', async (_label, error) => { + it('parks on an unknown local error instead of treating it as gone', async () => { const rig = createRig() const composition = rig.createComposition() await composition.start() - rig.hostOptions.onError?.(error()) + rig.hostOptions.onError?.(new Error('Stale browser host page command')) expect(composition.isParked).toBe(true) expect(rig.onError).not.toHaveBeenCalled() @@ -367,6 +358,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { await composition.resume() expect(rig.hosts).toHaveLength(2) expect(composition.isParked).toBe(false) + expect(rig.onParkedChange.mock.calls).toEqual([[true, expect.any(Error)], [false]]) // A runtime that can rekey kept guests gets them back in place: nothing is released or closed. expect(rig.executor.retirePage).not.toHaveBeenCalled() expect(rig.executor.close).not.toHaveBeenCalled() @@ -379,24 +371,48 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { ]) }) - it('re-attaches straight away after a dark route parks a healthy lease', async () => { + it('swaps a live lease for a fresh one without parking, as a dark route asks', async () => { const rig = createRig({ initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } }) const composition = rig.createComposition() await composition.start() - // The route owner parks and then asks for one resume; a lease that still answers takes it. - composition.park(new Error('browser network route stayed unavailable')) - expect(rig.routes.suspend).toHaveBeenCalled() - await composition.resume() + await composition.reattach() - expect(composition.isParked).toBe(false) expect(rig.hosts).toHaveLength(2) + expect(rig.onParkedChange).not.toHaveBeenCalled() expect(rig.onError).not.toHaveBeenCalled() expect(rig.executor.retirePage).not.toHaveBeenCalled() }) + it('stays parked when the lease is lost again while a re-attach is in flight', async () => { + vi.useFakeTimers() + try { + const rig = createRig({ + parkedGuestDiscardMs: 60_000, + initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } + }) + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + const gate = rig.holdNextStart() + + const resuming = composition.resume() + await vi.waitFor(() => expect(rig.hosts).toHaveLength(2)) + rig.hostOptions.onError?.(contactLostError()) + gate.resolve() + + await expect(resuming).rejects.toThrow('browser_client_host_reattach_superseded') + expect(composition.isParked).toBe(true) + vi.advanceTimersByTime(60_000) + expect(rig.executor.retirePage).toHaveBeenCalledWith('page-a', 7) + await composition.close() + } finally { + vi.useRealTimers() + } + }) + it('leaves a live composition alone when a trigger fires', async () => { const rig = createRig() const composition = rig.createComposition() @@ -470,6 +486,24 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { } }) + it('frees only the guests it told an older runtime about, sparing pages recovery placed', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + // The runtime's recovery recreates page-b before the attach answer is acted on. + rig.afterNextAttach(() => + rig.executor.snapshotPageInventory.mockReturnValue([ + retainedPageInventory(), + { ...retainedPageInventory(), browserPageId: 'page-b', pageHostGeneration: 9 } + ]) + ) + + await composition.resume() + + expect(rig.executor.retirePage.mock.calls).toEqual([['page-a', 7]]) + }) + it('closes a parked composition when the runtime refuses the re-attach', async () => { const rig = createRig() const composition = rig.createComposition() @@ -646,6 +680,8 @@ function createRig( ) { const initialAuthority = options.initialAuthority ?? authority const startFailures: Error[] = [] + const startGates: Promise[] = [] + const afterAttach: (() => void)[] = [] const order: string[] = [] let onPageUnavailable = (_browserPageId: string, _pageHostGeneration: number): void => {} let settleHandlers = (): void => {} @@ -724,11 +760,14 @@ function createRig( if (failure) { throw failure } + const inventory = callbacks.getPageInventory?.() ?? [] + afterAttach.shift()?.() + await startGates.shift() const nextAuthority = replacement ? (options.replacementAuthority ?? replacementAuthority) : initialAuthority if (replacement) { - replacementInventory = callbacks.getPageInventory?.() ?? [] + replacementInventory = inventory order.push('attach-replacement-inventory') } callbacks.onAuthority?.(nextAuthority) @@ -750,9 +789,17 @@ function createRig( }) }) const onError = vi.fn() + const onParkedChange = vi.fn() return { order, + onParkedChange, failNextStart: (error: Error) => startFailures.push(error), + afterNextAttach: (run: () => void) => afterAttach.push(run), + holdNextStart: () => { + const gate = deferred() + startGates.push(gate.promise) + return gate + }, routes, replacementRoutes, executor, @@ -798,7 +845,8 @@ function createRig( ...(options.parkedGuestDiscardMs ? { parkedGuestDiscardMs: options.parkedGuestDiscardMs } : {}), - onError + onError, + onParkedChange }) } } diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.ts b/src/main/browser/paired-runtime-browser-client-host-composition.ts index 2c6087b1c314..0320a05ab576 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.ts @@ -4,8 +4,7 @@ import type { BrowserClientHostCommandResult, BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' -import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' -import { BrowserHostAnswerError, isBrowserHostRefusal } from './browser-host-admission-recovery' +import { browserHostRefusal, isFinalBrowserHostRefusal } from './browser-host-admission-recovery' import { asCompositionError, closeBrowserClientHostComposition, @@ -35,7 +34,9 @@ export class PairedRuntimeBrowserClientHostComposition< private errorReported = false private inventoryRefreshPromise: Promise | null = null private input: Start - private lastAuthority: BrowserClientHostLeaseAuthority | null = null + private attached = false + /** The current lease's attach inventory: the only guests its runtime was told about. */ + private publishedInventory: readonly BrowserClientHostedPageInventory[] | null = null /** Set while the runtime is unreachable: pages and suspended routes are kept for its return. */ private parked: { discardTimer: ReturnType | null } | null = null @@ -66,34 +67,26 @@ export class PairedRuntimeBrowserClientHostComposition< } replaceAuthority(input: Start): Promise { - if (this.closed) { - return Promise.reject(new Error('paired_runtime_browser_client_host_composition_closed')) - } this.input = input - this.startPromise = this.reattach(input, false) - return this.startPromise + return this.beginReattach(false) + } + + /** Swaps the lease for a fresh one with the same runtime, keeping every guest. */ + reattach(): Promise { + return this.beginReattach(true) } /** One re-attach for a parked composition; a live one answers with its current lease. */ resume(): Promise { - if (this.closed || !this.parked) { - return this.start() - } - this.startPromise = this.reattach(this.input, true) - return this.startPromise + return this.parked ? this.reattach() : this.start() } /** Holds the pages for the runtime's return; only `close` gives them up. */ - park(error: Error): void { - if (this.closed) { - return - } + private park(error: Error): void { if (!this.parked) { - console.warn('[browser-client-host] runtime unreachable; keeping pages until it returns:', { - error: error.message - }) + this.parked = { discardTimer: null } + this.options.onParkedChange?.(true, error) } - this.parked ??= { discardTimer: null } this.parked.discardTimer ??= scheduleParkedGuestDiscard( this.executor, this.options.parkedGuestDiscardMs @@ -127,8 +120,7 @@ export class PairedRuntimeBrowserClientHostComposition< close(error = new Error('Browser client host composition is closed')): Promise { if (!this.closed) { this.closed = true - this.clearGuestDiscard() - this.parked = null + this.unpark() this.hostGeneration += 1 try { this.options.onClosing?.() @@ -151,31 +143,30 @@ export class PairedRuntimeBrowserClientHostComposition< private createHost(input: Start, requiresReconciliation: boolean): ComposedClientHost { const generation = ++this.hostGeneration - let publishedInventory: readonly BrowserClientHostedPageInventory[] | null = null + this.publishedInventory = null return this.options.createHost(input, { handler: (event, signal) => this.handleCommand(generation, event, signal), getPageInventory: () => { if (this.hostGeneration !== generation) { return [] } - publishedInventory = this.executor.snapshotPageInventory() - return publishedInventory + this.publishedInventory = this.executor.snapshotPageInventory() + return this.publishedInventory }, onAuthority: (authority) => { if (this.hostGeneration === generation) { if ( requiresReconciliation && - publishedInventory?.length && + this.publishedInventory?.length && authority.pageReconciliationProtocolVersion !== 1 ) { - // Read from the runtime's own ready answer, so it is as final as a refusal. - throw new BrowserHostAnswerError( + throw browserHostRefusal( 'browser_client_page_reconciliation_unsupported', - 'browser_client_page_reconciliation_unsupported' + 'The runtime cannot take back pages this desktop kept.' ) } this.routeSets.activate(input, authority) - this.lastAuthority = authority + this.attached = true } }, onTransportLost: (error) => { @@ -201,10 +192,16 @@ export class PairedRuntimeBrowserClientHostComposition< * Swaps the lease for a fresh one under `input`, keeping the executor so its guests survive; the * attach inventory is what lets the runtime rekey them in place. */ - private async reattach( - input: Start, - sameRuntime: boolean - ): Promise { + private beginReattach(sameRuntime: boolean): Promise { + if (this.closed) { + return Promise.reject(new Error('paired_runtime_browser_client_host_composition_closed')) + } + this.startPromise = this.swapLease(sameRuntime) + return this.startPromise + } + + private async swapLease(sameRuntime: boolean): Promise { + const input = this.input this.clearGuestDiscard() const error = new Error('Browser client host is re-attaching') this.hostGeneration += 1 @@ -223,14 +220,19 @@ export class PairedRuntimeBrowserClientHostComposition< } try { this.host = this.createHost(input, true) + const generation = this.hostGeneration const authority = await this.host.start() // An older runtime cannot rekey a kept guest and leaves it placed nowhere. Only its answer - // says so; freeing the guests and republishing lets its recovery reload them at their URL. + // says so; freeing what it was told about lets its recovery reload them at their URL, and + // spares any page that recovery already placed under a newer generation. if (sameRuntime && authority.returningHostReclaimProtocolVersion !== 1) { - await releaseBrowserClientGuests(this.executor) + await releaseBrowserClientGuests(this.executor, this.publishedInventory ?? []) await this.host.refreshPageInventory() } - this.parked = null + if (generation !== this.hostGeneration) { + throw new Error('browser_client_host_reattach_superseded') + } + this.unpark() return authority } catch (attachError) { this.parkOrFail(asCompositionError(attachError)) @@ -240,15 +242,19 @@ export class PairedRuntimeBrowserClientHostComposition< /** Only the runtime's own refusal is final; anything else may be lost contact and is waited out. */ private parkOrFail(error: Error): void { - if ( - !this.closed && - this.lastAuthority !== null && - (!isBrowserHostRefusal(error) || isBrowserClientHostAuthorityReplaced(error)) - ) { + if (this.closed || !this.attached || isFinalBrowserHostRefusal(error)) { + this.handleHostError(error) + } else { this.park(error) - return } - this.handleHostError(error) + } + + private unpark(): void { + this.clearGuestDiscard() + if (this.parked) { + this.parked = null + this.options.onParkedChange?.(false) + } } private clearGuestDiscard(): void { diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts index 52dc67dbc55b..6aaa54f1d6c6 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.test.ts @@ -130,8 +130,12 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { await registry.resume('environment-b') await registry.resume('environment-missing') - expect(parked.resume).toHaveBeenCalledOnce() - expect(live.resume).not.toHaveBeenCalled() + expect(parked.reattach).toHaveBeenCalledOnce() + expect(live.reattach).not.toHaveBeenCalled() + + // A dark route on a live lease swaps it without passing through the parked state. + await registry.reattach('environment-b') + expect(live.reattach).toHaveBeenCalledOnce() }) it('blocks replacement when the old host cannot prove handler settlement', async () => { @@ -231,6 +235,7 @@ function createComposition( return authority }), resume: vi.fn(async () => authority), + reattach: vi.fn(async () => authority), replaceAuthority: vi.fn(async () => ({ ...authority, authorityRuntimeId: 'runtime-b' })), retirePage: vi.fn(async () => true), close: vi.fn(async () => { diff --git a/src/main/browser/paired-runtime-browser-client-host-registry.ts b/src/main/browser/paired-runtime-browser-client-host-registry.ts index ea9493d6b88b..8985a02072d2 100644 --- a/src/main/browser/paired-runtime-browser-client-host-registry.ts +++ b/src/main/browser/paired-runtime-browser-client-host-registry.ts @@ -12,6 +12,7 @@ type RegisteredBrowserClientHost = { /** True while the runtime is unreachable and the composition is holding its pages for it. */ readonly isParked: boolean resume(): Promise + reattach(): Promise retirePage(browserPageId: string, pageHostGeneration: number): Promise close(error?: Error): Promise whenClosed(): Promise @@ -124,12 +125,24 @@ export class PairedRuntimeBrowserClientHostRegistry< /** One re-attach if the environment's host is parked; a live or absent one is untouched. */ resume(environmentId: string): Promise { + return this.reattachWhen(environmentId, (composition) => composition.isParked) + } + + /** Swaps a live environment's lease for a fresh one without parking it first. */ + reattach(environmentId: string): Promise { + return this.reattachWhen(environmentId, () => true) + } + + private reattachWhen( + environmentId: string, + applies: (composition: RegisteredBrowserClientHost) => boolean + ): Promise { return this.enqueue(environmentId, async () => { const record = this.hosts.get(environmentId) - if (this.closed || !record?.composition.isParked || record.cleanupPending) { + if (this.closed || !record || record.cleanupPending || !applies(record.composition)) { return } - record.authority = record.composition.resume() + record.authority = record.composition.reattach() await record.authority.catch(() => undefined) }) } diff --git a/src/main/browser/paired-runtime-browser-client-host-runtime.ts b/src/main/browser/paired-runtime-browser-client-host-runtime.ts index 2017f1fa4d70..2f463c5d5380 100644 --- a/src/main/browser/paired-runtime-browser-client-host-runtime.ts +++ b/src/main/browser/paired-runtime-browser-client-host-runtime.ts @@ -30,6 +30,7 @@ import { import { selectBrowserClientPageRenderer } from './browser-client-page-renderer-runtime' import { PairedRuntimeBrowserClientHostComposition } from './paired-runtime-browser-client-host-composition' import { PairedRuntimeBrowserClientHost } from './paired-runtime-browser-client-host' +import { publishBrowserClientHostParked } from './browser-client-host-parked-publication' import { PairedRuntimeBrowserClientHostRegistry, type PairedRuntimeBrowserClientHostStart @@ -86,14 +87,15 @@ const browserClientHosts = const composition = new PairedRuntimeBrowserClientHostComposition({ onClosing: routes.release, parkedGuestDiscardMs: e2eParkedGuestDiscardMs(), + onParkedChange: (parked, error) => + publishBrowserClientHostParked(input.environmentId, parked, error), initialInput: input, - // Why park, not retire: a route that stayed dark is lost contact, not lost pages. The - // immediate resume keeps a healthy lease from sitting parked behind a live status. + // Why re-attach, not retire: a route that stayed dark is lost contact, not lost pages. If + // the fresh lease fails too, the composition parks or fails on that answer. createRoutes: (next, authority) => createNetworkRoutes(next.pairing, authority, next.storageScope, (error) => { reportBrowserClientHostError(error) - composition.park(error) - void browserClientHosts.resume(input.environmentId) + void browserClientHosts.reattach(input.environmentId) }), createExecutor: (next, { retainNetworkRoute, onPageUnavailable }) => { executor = new BrowserClientPageCommandExecutor({ @@ -238,6 +240,11 @@ export function retirePairedRuntimeBrowserClientHostEnvironment( return browserClientHosts.retireEnvironment(environmentId, error) } +/** One re-attach if the environment's browser host is parked; a live one is untouched. */ +export function resumePairedRuntimeBrowserClientHost(environmentId: string): Promise { + return browserClientHosts.resume(environmentId) +} + export function shutdownPairedRuntimeBrowserClientHosts(): Promise { clientHostRouteIdentities.clear() return browserClientHosts.close() diff --git a/src/main/browser/paired-runtime-browser-client-host-teardown.ts b/src/main/browser/paired-runtime-browser-client-host-teardown.ts index ac50151f0d0d..e982dc26ee5d 100644 --- a/src/main/browser/paired-runtime-browser-client-host-teardown.ts +++ b/src/main/browser/paired-runtime-browser-client-host-teardown.ts @@ -89,11 +89,12 @@ export function scheduleParkedGuestDiscard( } /** Frees every live guest; the runtime recreates each page at its last URL when it can. */ -export async function releaseBrowserClientGuests(executor: ReleasableGuests): Promise { +export async function releaseBrowserClientGuests( + executor: ReleasableGuests, + pages = executor.snapshotPageInventory() +): Promise { const results = await Promise.allSettled( - executor - .snapshotPageInventory() - .map((page) => executor.retirePage(page.browserPageId, page.pageHostGeneration)) + pages.map((page) => executor.retirePage(page.browserPageId, page.pageHostGeneration)) ) const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : [] diff --git a/src/main/ipc/runtime-environment-browser-client-host-handler.ts b/src/main/ipc/runtime-environment-browser-client-host-handler.ts index fb380c30c3d9..7a7182495994 100644 --- a/src/main/ipc/runtime-environment-browser-client-host-handler.ts +++ b/src/main/ipc/runtime-environment-browser-client-host-handler.ts @@ -7,9 +7,11 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import { resolveEnvironment } from '../../shared/runtime-environment-store' import { closePairedRuntimeBrowserClientHostEnvironment, + resumePairedRuntimeBrowserClientHost, startPairedRuntimeBrowserClientHost } from '../browser/paired-runtime-browser-client-host-runtime' import { prepareBrowserClientHostPlacement } from '../browser/browser-client-host-placement-preparation' +import { isBrowserClientHostEnvironmentParked } from '../browser/browser-client-host-parked-publication' import { isRuntimeEnvironmentManuallyDisconnected } from './runtime-environment-connectivity-handlers' import { getRuntimeEnvironmentStatus } from './runtime-environment-transport-routing' @@ -55,6 +57,17 @@ export function registerRuntimeEnvironmentBrowserClientHostHandler(options: { return placement } ) + // Why apart from preparation: opening a tab must not cost a status round-trip when healthy. + ipcMain.handle( + 'runtimeEnvironments:resumeBrowserClientHost', + async (_event, args: { selector: string }): Promise => { + const environment = resolveEnvironment(options.getUserDataPath(), args.selector) + if (!isRuntimeEnvironmentManuallyDisconnected(environment.id)) { + await resumePairedRuntimeBrowserClientHost(environment.id) + } + return isBrowserClientHostEnvironmentParked(environment.id) + } + ) } function requireConnected(environmentId: string): void { diff --git a/src/main/ipc/runtime-environment-handler-channels.ts b/src/main/ipc/runtime-environment-handler-channels.ts index 23b40fe51834..f08b506b34fc 100644 --- a/src/main/ipc/runtime-environment-handler-channels.ts +++ b/src/main/ipc/runtime-environment-handler-channels.ts @@ -8,6 +8,7 @@ export const RUNTIME_ENVIRONMENT_HANDLER_CHANNELS = [ 'runtimeEnvironments:connect', 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', + 'runtimeEnvironments:resumeBrowserClientHost', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', diff --git a/src/main/ipc/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments-pairing.test.ts index 6919c68f198a..268d681be89c 100644 --- a/src/main/ipc/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments-pairing.test.ts @@ -149,6 +149,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:connect', 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', + 'runtimeEnvironments:resumeBrowserClientHost', 'runtimeEnvironments:retryConnectionsNow', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', @@ -173,6 +174,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:connect', 'runtimeEnvironments:retryControlConnection', 'runtimeEnvironments:prepareBrowserClientHostPlacement', + 'runtimeEnvironments:resumeBrowserClientHost', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:getStatusSnapshots', 'runtimeEnvironments:call', diff --git a/src/preload/api/runtime-api.ts b/src/preload/api/runtime-api.ts index f158699ee47b..8de37d17f424 100644 --- a/src/preload/api/runtime-api.ts +++ b/src/preload/api/runtime-api.ts @@ -8,6 +8,7 @@ import type { } from '../../shared/runtime-types' import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' +import type { BrowserClientHostParkedEvent } from '../../shared/browser-client-host-parked' import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' import type { VerifyAndAddRuntimeEnvironmentResult } from '../../shared/remote-pairing-verification' import type { @@ -115,6 +116,11 @@ export type RuntimeApi = { prepareBrowserClientHostPlacement: ( args: BrowserClientHostPlacementPreparationRequest ) => Promise + /** One re-attach if this desktop's browser host is parked; answers whether it still is. */ + resumeBrowserClientHost?: (args: { selector: string }) => Promise + onBrowserClientHostParked?: ( + callback: (event: BrowserClientHostParkedEvent) => void + ) => () => void // Why: system resume / browser online advance pending shared-control reconnect timers only. retryConnectionsNow?: () => Promise call: (args: { diff --git a/src/preload/api/runtime-environments-bridge.ts b/src/preload/api/runtime-environments-bridge.ts index 25cc43e1f61e..43f85df4cc2b 100644 --- a/src/preload/api/runtime-environments-bridge.ts +++ b/src/preload/api/runtime-environments-bridge.ts @@ -9,6 +9,10 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { PublicKnownRuntimeEnvironment } from '../../shared/runtime-environments' import type { RemoteRuntimeSharedConnectionDiagnostics } from '../../shared/remote-runtime-shared-control-types' import { RUNTIME_ENVIRONMENT_DIAGNOSTICS_CHANNEL } from '../../shared/runtime-environment-diagnostics' +import { + BROWSER_CLIENT_HOST_PARKED_CHANNEL, + type BrowserClientHostParkedEvent +} from '../../shared/browser-client-host-parked' import { subscribeRuntimeEnvironmentFromPreload, type RuntimeEnvironmentSubscriptionHandle @@ -80,6 +84,18 @@ export const runtimeEnvironmentsApi = { }, prepareBrowserClientHostPlacement: (args) => ipcRenderer.invoke('runtimeEnvironments:prepareBrowserClientHostPlacement', args), + resumeBrowserClientHost: (args: { selector: string }): Promise => + ipcRenderer.invoke('runtimeEnvironments:resumeBrowserClientHost', args), + onBrowserClientHostParked: ( + callback: (event: BrowserClientHostParkedEvent) => void + ): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: BrowserClientHostParkedEvent + ): void => callback(data) + ipcRenderer.on(BROWSER_CLIENT_HOST_PARKED_CHANNEL, listener) + return () => ipcRenderer.removeListener(BROWSER_CLIENT_HOST_PARKED_CHANNEL, listener) + }, retryConnectionsNow: (): Promise => ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), call: (args: { diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx index 9da9f314d640..fbba92522ba8 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx @@ -1,14 +1,17 @@ // @vitest-environment happy-dom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { resetBrowserClientHostIdForTests } from '@/runtime/browser-client-host-identity' -import { resetRestoredBrowserClientHostAttachForTests } from '@/runtime/restored-client-hosted-browser-host-attach' +import { + resetBrowserClientHostParkedForTests, + setBrowserClientHostParked +} from '@/runtime/browser-client-host-parked-environments' import { installClientHostedPaneApi } from './client-hosted-browser-pane-test-rig' import { ClientHostedBrowserAvailabilityNotice } from './client-hosted-browser-unavailable-notice' const retryControlConnection = vi.fn(async () => {}) -const prepareBrowserClientHostPlacement = vi.fn(async () => ({ kind: 'client' })) +const resumeBrowserClientHost = vi.fn(async () => false) function renderNotice( props: Partial[0]> = {} @@ -26,14 +29,9 @@ function renderNotice( ) } -const clientHostedPage = { environmentId: 'env-a', placement: { kind: 'client' } } -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach reads only environmentId and placement. -const CLIENT_HOSTED_PAGE_HANDLES = { 'page-a': clientHostedPage } as never - function setHostOffline(offline: boolean): void { useAppStore.setState({ runtimeStatusByEnvironmentId: offline ? new Map([['env-a', { status: null }]]) : new Map(), - remoteBrowserPageHandlesByPageId: CLIENT_HOSTED_PAGE_HANDLES, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the notice reads only id and name. runtimeEnvironments: [{ id: 'env-a', name: 'Build box' }] as never }) @@ -42,12 +40,12 @@ function setHostOffline(offline: boolean): void { describe('ClientHostedBrowserAvailabilityNotice', () => { beforeEach(() => { resetBrowserClientHostIdForTests() - resetRestoredBrowserClientHostAttachForTests() + resetBrowserClientHostParkedForTests() retryControlConnection.mockClear() - prepareBrowserClientHostPlacement.mockClear() + resumeBrowserClientHost.mockClear() installClientHostedPaneApi({ browser: { readClientHostId: () => 'this-desktop' }, - runtimeEnvironments: { retryControlConnection, prepareBrowserClientHostPlacement } + runtimeEnvironments: { retryControlConnection, resumeBrowserClientHost } }) }) afterEach(() => { @@ -69,10 +67,17 @@ describe('ClientHostedBrowserAvailabilityNotice', () => { expect(screen.getByRole('status').textContent).toContain('Build box is offline') fireEvent.click(screen.getByRole('button', { name: 'Reconnect now' })) expect(retryControlConnection).toHaveBeenCalledWith({ selector: 'env-a' }) - expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ - selector: 'env-a', - preference: 'auto' - }) + expect(resumeBrowserClientHost).toHaveBeenCalledWith({ selector: 'env-a' }) + }) + + it('shows the strip while the browser host is parked even with the control link up', () => { + setHostOffline(false) + act(() => setBrowserClientHostParked('env-a', true)) + renderNotice() + + expect(screen.getByRole('status').textContent).toContain('Build box is offline') + act(() => setBrowserClientHostParked('env-a', false)) + expect(screen.queryByRole('status')).toBeNull() }) it('waits for an offline host instead of calling a missing guest unavailable', () => { @@ -97,10 +102,7 @@ describe('ClientHostedBrowserAvailabilityNotice', () => { setHostOffline(false) renderNotice({ isActive: true }) - expect(prepareBrowserClientHostPlacement).toHaveBeenCalledWith({ - selector: 'env-a', - preference: 'auto' - }) + expect(resumeBrowserClientHost).toHaveBeenCalledWith({ selector: 'env-a' }) expect(retryControlConnection).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts index cbe9f66c8393..e38213940250 100644 --- a/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts +++ b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts @@ -1,14 +1,17 @@ import { useEffect } from 'react' import { useAppStore } from '@/store' import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' -import { ensureBrowserClientHostForReturningRuntime } from '@/runtime/restored-client-hosted-browser-host-attach' +import { + resumeBrowserClientHost, + useBrowserClientHostParked +} from '@/runtime/browser-client-host-parked-environments' /** Retries the host connection now, and re-attaches this environment's browser host if parked. */ export function requestClientHostedBrowserReconnect(runtimeEnvironmentId: string): void { void window.api.runtimeEnvironments .retryControlConnection?.({ selector: runtimeEnvironmentId }) .catch(() => undefined) - void ensureBrowserClientHostForReturningRuntime(useAppStore.getState(), runtimeEnvironmentId) + void resumeBrowserClientHost(runtimeEnvironmentId) } /** @@ -22,8 +25,11 @@ export function useClientHostedBrowserHostReachability({ runtimeEnvironmentId: string isActive: boolean }): { hostOffline: boolean; hostName: string | null } { + // Why both: parked is main's own verdict even while the control link is up; an unreachable control + // link still explains a missing guest when no browser host was ever started to park. + const parked = useBrowserClientHostParked(runtimeEnvironmentId) // Why only a known entry: no status yet is "not checked", and that is not evidence of offline. - const hostOffline = useAppStore((s) => { + const controlOffline = useAppStore((s) => { const entry = s.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId) const state = runtimeHostConnectionStateForEntry(entry) return entry !== undefined && (state === 'reconnecting' || state === 'disconnected') @@ -35,8 +41,8 @@ export function useClientHostedBrowserHostReachability({ ) useEffect(() => { if (isActive) { - void ensureBrowserClientHostForReturningRuntime(useAppStore.getState(), runtimeEnvironmentId) + void resumeBrowserClientHost(runtimeEnvironmentId) } }, [isActive, runtimeEnvironmentId]) - return { hostOffline, hostName } + return { hostOffline: parked || controlOffline, hostName } } diff --git a/src/renderer/src/runtime/browser-client-host-parked-environments.ts b/src/renderer/src/runtime/browser-client-host-parked-environments.ts new file mode 100644 index 000000000000..37a95bbba2b5 --- /dev/null +++ b/src/renderer/src/runtime/browser-client-host-parked-environments.ts @@ -0,0 +1,50 @@ +import { useSyncExternalStore } from 'react' + +// Main's verdict per environment: this desktop's browser host is holding its pages for the runtime. +const parkedEnvironmentIds = new Set() +const listeners = new Set<() => void>() +let mainSubscription: (() => void) | null = null + +export function setBrowserClientHostParked(environmentId: string, parked: boolean): void { + if (parkedEnvironmentIds.has(environmentId) === parked) { + return + } + if (parked) { + parkedEnvironmentIds.add(environmentId) + } else { + parkedEnvironmentIds.delete(environmentId) + } + for (const listener of listeners) { + listener() + } +} + +/** One re-attach if parked; the answer also re-syncs a renderer that missed the push. */ +export async function resumeBrowserClientHost(environmentId: string): Promise { + const parked = await window.api.runtimeEnvironments + .resumeBrowserClientHost?.({ selector: environmentId }) + .catch(() => null) + if (typeof parked === 'boolean') { + setBrowserClientHostParked(environmentId, parked) + } +} + +function subscribe(listener: () => void): () => void { + mainSubscription ??= + window.api.runtimeEnvironments.onBrowserClientHostParked?.((event) => + setBrowserClientHostParked(event.environmentId, event.parked) + ) ?? null + listeners.add(listener) + return () => listeners.delete(listener) +} + +export function useBrowserClientHostParked(environmentId: string): boolean { + return useSyncExternalStore(subscribe, () => parkedEnvironmentIds.has(environmentId)) +} + +export function resetBrowserClientHostParkedForTests(): void { + mainSubscription?.() + mainSubscription = null + parkedEnvironmentIds.clear() + listeners.clear() +} diff --git a/src/shared/browser-client-host-parked.ts b/src/shared/browser-client-host-parked.ts new file mode 100644 index 000000000000..94f3fb6591ae --- /dev/null +++ b/src/shared/browser-client-host-parked.ts @@ -0,0 +1,4 @@ +/** Main → renderer: this desktop's browser host for an environment parked or resumed. */ +export const BROWSER_CLIENT_HOST_PARKED_CHANNEL = 'runtimeEnvironments:browserClientHostParked' + +export type BrowserClientHostParkedEvent = { environmentId: string; parked: boolean } From ac388af584a94fc73f868827976d2cee4b203ef9 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 6 Oct 2026 03:00:11 -0400 Subject: [PATCH 12/12] fix(remote-browser): park only for runtimes that can take kept pages back Drops the older-runtime fallback that freed guests after attach and asked the runtime to reload them. Whether a runtime can rekey kept guests is known from the lease's own ready answer, so a runtime without that capability fails lost contact and a dark route exactly as before; only a replaced runtime is still waited for. The e2e older-host case now pins that nothing is kept. --- ...me-browser-client-host-composition.test.ts | 86 ++++++++----------- ...runtime-browser-client-host-composition.ts | 58 ++++++------- ...red-runtime-browser-client-host-runtime.ts | 13 +-- ...ed-runtime-browser-client-host-teardown.ts | 9 +- ...ient-hosted-browser-network-outage.spec.ts | 20 ++--- 5 files changed, 84 insertions(+), 102 deletions(-) diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts index 7f57e058a81d..c9ffc1ae29b2 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.test.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.test.ts @@ -10,6 +10,17 @@ import { BROWSER_CLIENT_HOST_AUTHORITY_MISMATCH_CODE } from '../../shared/browse import { PairedRuntimeBrowserClientHostComposition } from './paired-runtime-browser-client-host-composition' const authority: BrowserClientHostLeaseAuthority = { + authorityRuntimeId: 'runtime-a', + authorityEpoch: 'epoch-a', + browserHostClientId: 'client-a', + browserHostGeneration: 4, + pageCommandProtocolVersion: 1, + pageReconciliationProtocolVersion: 1, + returningHostReclaimProtocolVersion: 1 +} + +/** A runtime from before reclaim: it never echoes the capability. */ +const olderAuthority: BrowserClientHostLeaseAuthority = { authorityRuntimeId: 'runtime-a', authorityEpoch: 'epoch-a', browserHostClientId: 'client-a', @@ -348,9 +359,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { }) it('re-attaches a parked composition in place when the runtime can rekey kept guests', async () => { - const rig = createRig({ - initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } - }) + const rig = createRig() const composition = rig.createComposition() await composition.start() rig.hostOptions.onError?.(contactLostError()) @@ -372,9 +381,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { }) it('swaps a live lease for a fresh one without parking, as a dark route asks', async () => { - const rig = createRig({ - initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } - }) + const rig = createRig() const composition = rig.createComposition() await composition.start() @@ -390,8 +397,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { vi.useFakeTimers() try { const rig = createRig({ - parkedGuestDiscardMs: 60_000, - initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } + parkedGuestDiscardMs: 60_000 }) const composition = rig.createComposition() await composition.start() @@ -423,31 +429,8 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { expect(rig.hosts).toHaveLength(1) }) - it('frees kept guests only once a re-attach shows the runtime cannot rekey them', async () => { - const rig = createRig() - const composition = rig.createComposition() - await composition.start() - rig.hostOptions.onError?.(contactLostError()) - rig.failNextStart(unreachableError()) - - await expect(composition.resume()).rejects.toThrow('remote runtime unavailable') - // No answer is no evidence about the runtime's version: the guests stay. - expect(rig.executor.retirePage).not.toHaveBeenCalled() - - await composition.resume() - - // The older runtime recreates each page at its last URL; a kept guest would be placed nowhere. - expect(rig.order.indexOf('retire-executor-page')).toBeGreaterThan( - rig.order.lastIndexOf('activate-routes') - ) - expect(rig.hosts.at(-1)!.refreshPageInventory).toHaveBeenCalledOnce() - expect(rig.executor.close).not.toHaveBeenCalled() - }) - it('stays parked when a re-attach fails, and the next trigger tries again', async () => { - const rig = createRig({ - initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } - }) + const rig = createRig() const composition = rig.createComposition() await composition.start() rig.hostOptions.onError?.(contactLostError()) @@ -486,22 +469,31 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { } }) - it('frees only the guests it told an older runtime about, sparing pages recovery placed', async () => { - const rig = createRig() + it('fails on lost contact as before when the runtime cannot take kept guests back', async () => { + const rig = createRig({ initialAuthority: olderAuthority }) const composition = rig.createComposition() await composition.start() - rig.hostOptions.onError?.(contactLostError()) - // The runtime's recovery recreates page-b before the attach answer is acted on. - rig.afterNextAttach(() => - rig.executor.snapshotPageInventory.mockReturnValue([ - retainedPageInventory(), - { ...retainedPageInventory(), browserPageId: 'page-b', pageHostGeneration: 9 } - ]) - ) + const lost = contactLostError() - await composition.resume() + rig.hostOptions.onError?.(lost) - expect(rig.executor.retirePage.mock.calls).toEqual([['page-a', 7]]) + expect(composition.isParked).toBe(false) + expect(composition.canPark).toBe(false) + expect(rig.onError).toHaveBeenCalledWith(lost) + await composition.whenClosed() + expect(rig.executor.close).toHaveBeenCalledOnce() + }) + + it('still waits for a replaced runtime when the runtime cannot take kept guests back', async () => { + const rig = createRig({ initialAuthority: olderAuthority }) + const composition = rig.createComposition() + await composition.start() + + rig.hostOptions.onError?.(authorityReplacedError()) + + expect(composition.isParked).toBe(true) + expect(rig.onError).not.toHaveBeenCalled() + await composition.close() }) it('closes a parked composition when the runtime refuses the re-attach', async () => { @@ -547,8 +539,7 @@ describe('PairedRuntimeBrowserClientHostComposition', () => { vi.useFakeTimers() try { const rig = createRig({ - parkedGuestDiscardMs: 60_000, - initialAuthority: { ...authority, returningHostReclaimProtocolVersion: 1 } + parkedGuestDiscardMs: 60_000 }) const composition = rig.createComposition() await composition.start() @@ -681,7 +672,6 @@ function createRig( const initialAuthority = options.initialAuthority ?? authority const startFailures: Error[] = [] const startGates: Promise[] = [] - const afterAttach: (() => void)[] = [] const order: string[] = [] let onPageUnavailable = (_browserPageId: string, _pageHostGeneration: number): void => {} let settleHandlers = (): void => {} @@ -761,7 +751,6 @@ function createRig( throw failure } const inventory = callbacks.getPageInventory?.() ?? [] - afterAttach.shift()?.() await startGates.shift() const nextAuthority = replacement ? (options.replacementAuthority ?? replacementAuthority) @@ -794,7 +783,6 @@ function createRig( order, onParkedChange, failNextStart: (error: Error) => startFailures.push(error), - afterNextAttach: (run: () => void) => afterAttach.push(run), holdNextStart: () => { const gate = deferred() startGates.push(gate.promise) diff --git a/src/main/browser/paired-runtime-browser-client-host-composition.ts b/src/main/browser/paired-runtime-browser-client-host-composition.ts index 0320a05ab576..a9879885e77c 100644 --- a/src/main/browser/paired-runtime-browser-client-host-composition.ts +++ b/src/main/browser/paired-runtime-browser-client-host-composition.ts @@ -4,11 +4,11 @@ import type { BrowserClientHostCommandResult, BrowserClientHostLeaseAuthority } from '../../shared/browser-client-host-protocol' +import { isBrowserClientHostAuthorityReplaced } from './browser-client-host-authority-replacement' import { browserHostRefusal, isFinalBrowserHostRefusal } from './browser-host-admission-recovery' import { asCompositionError, closeBrowserClientHostComposition, - releaseBrowserClientGuests, scheduleParkedGuestDiscard, warnParkedCleanup } from './paired-runtime-browser-client-host-teardown' @@ -34,9 +34,8 @@ export class PairedRuntimeBrowserClientHostComposition< private errorReported = false private inventoryRefreshPromise: Promise | null = null private input: Start - private attached = false - /** The current lease's attach inventory: the only guests its runtime was told about. */ - private publishedInventory: readonly BrowserClientHostedPageInventory[] | null = null + /** The current runtime can rekey kept guests; without that an outage fails as it always did. */ + private reclaimable = false /** Set while the runtime is unreachable: pages and suspended routes are kept for its return. */ private parked: { discardTimer: ReturnType | null } | null = null @@ -66,14 +65,22 @@ export class PairedRuntimeBrowserClientHostComposition< return this.parked !== null } + get canPark(): boolean { + return this.reclaimable + } + replaceAuthority(input: Start): Promise { this.input = input - return this.beginReattach(false) + return this.reattach() } - /** Swaps the lease for a fresh one with the same runtime, keeping every guest. */ + /** Swaps the lease for a fresh one under the current input, keeping every guest. */ reattach(): Promise { - return this.beginReattach(true) + if (this.closed) { + return Promise.reject(new Error('paired_runtime_browser_client_host_composition_closed')) + } + this.startPromise = this.swapLease() + return this.startPromise } /** One re-attach for a parked composition; a live one answers with its current lease. */ @@ -143,21 +150,21 @@ export class PairedRuntimeBrowserClientHostComposition< private createHost(input: Start, requiresReconciliation: boolean): ComposedClientHost { const generation = ++this.hostGeneration - this.publishedInventory = null + let publishedInventory: readonly BrowserClientHostedPageInventory[] | null = null return this.options.createHost(input, { handler: (event, signal) => this.handleCommand(generation, event, signal), getPageInventory: () => { if (this.hostGeneration !== generation) { return [] } - this.publishedInventory = this.executor.snapshotPageInventory() - return this.publishedInventory + publishedInventory = this.executor.snapshotPageInventory() + return publishedInventory }, onAuthority: (authority) => { if (this.hostGeneration === generation) { if ( requiresReconciliation && - this.publishedInventory?.length && + publishedInventory?.length && authority.pageReconciliationProtocolVersion !== 1 ) { throw browserHostRefusal( @@ -166,7 +173,7 @@ export class PairedRuntimeBrowserClientHostComposition< ) } this.routeSets.activate(input, authority) - this.attached = true + this.reclaimable = authority.returningHostReclaimProtocolVersion === 1 } }, onTransportLost: (error) => { @@ -192,15 +199,7 @@ export class PairedRuntimeBrowserClientHostComposition< * Swaps the lease for a fresh one under `input`, keeping the executor so its guests survive; the * attach inventory is what lets the runtime rekey them in place. */ - private beginReattach(sameRuntime: boolean): Promise { - if (this.closed) { - return Promise.reject(new Error('paired_runtime_browser_client_host_composition_closed')) - } - this.startPromise = this.swapLease(sameRuntime) - return this.startPromise - } - - private async swapLease(sameRuntime: boolean): Promise { + private async swapLease(): Promise { const input = this.input this.clearGuestDiscard() const error = new Error('Browser client host is re-attaching') @@ -222,13 +221,6 @@ export class PairedRuntimeBrowserClientHostComposition< this.host = this.createHost(input, true) const generation = this.hostGeneration const authority = await this.host.start() - // An older runtime cannot rekey a kept guest and leaves it placed nowhere. Only its answer - // says so; freeing what it was told about lets its recovery reload them at their URL, and - // spares any page that recovery already placed under a newer generation. - if (sameRuntime && authority.returningHostReclaimProtocolVersion !== 1) { - await releaseBrowserClientGuests(this.executor, this.publishedInventory ?? []) - await this.host.refreshPageInventory() - } if (generation !== this.hostGeneration) { throw new Error('browser_client_host_reattach_superseded') } @@ -240,9 +232,15 @@ export class PairedRuntimeBrowserClientHostComposition< } } - /** Only the runtime's own refusal is final; anything else may be lost contact and is waited out. */ + /** + * A replaced runtime is waited for, as always. Lost contact is waited out only when the runtime + * can take the kept guests back; a runtime's own refusal is final either way. + */ private parkOrFail(error: Error): void { - if (this.closed || !this.attached || isFinalBrowserHostRefusal(error)) { + const waits = + isBrowserClientHostAuthorityReplaced(error) || + (this.reclaimable && !isFinalBrowserHostRefusal(error)) + if (this.closed || !waits) { this.handleHostError(error) } else { this.park(error) diff --git a/src/main/browser/paired-runtime-browser-client-host-runtime.ts b/src/main/browser/paired-runtime-browser-client-host-runtime.ts index 2f463c5d5380..4647f78a58ad 100644 --- a/src/main/browser/paired-runtime-browser-client-host-runtime.ts +++ b/src/main/browser/paired-runtime-browser-client-host-runtime.ts @@ -90,13 +90,14 @@ const browserClientHosts = onParkedChange: (parked, error) => publishBrowserClientHostParked(input.environmentId, parked, error), initialInput: input, - // Why re-attach, not retire: a route that stayed dark is lost contact, not lost pages. If - // the fresh lease fails too, the composition parks or fails on that answer. + // Why re-attach, not retire: a route that stayed dark is lost contact, not lost pages, for a + // runtime that can take them back. If the fresh lease fails too, the composition decides. createRoutes: (next, authority) => - createNetworkRoutes(next.pairing, authority, next.storageScope, (error) => { - reportBrowserClientHostError(error) - void browserClientHosts.reattach(input.environmentId) - }), + createNetworkRoutes(next.pairing, authority, next.storageScope, (error) => + composition.canPark + ? void browserClientHosts.reattach(input.environmentId) + : retireFailedEnvironmentHost(input.environmentId, error) + ), createExecutor: (next, { retainNetworkRoute, onPageUnavailable }) => { executor = new BrowserClientPageCommandExecutor({ orcaProfileId: next.orcaProfileId, diff --git a/src/main/browser/paired-runtime-browser-client-host-teardown.ts b/src/main/browser/paired-runtime-browser-client-host-teardown.ts index e982dc26ee5d..fc6977536518 100644 --- a/src/main/browser/paired-runtime-browser-client-host-teardown.ts +++ b/src/main/browser/paired-runtime-browser-client-host-teardown.ts @@ -89,12 +89,11 @@ export function scheduleParkedGuestDiscard( } /** Frees every live guest; the runtime recreates each page at its last URL when it can. */ -export async function releaseBrowserClientGuests( - executor: ReleasableGuests, - pages = executor.snapshotPageInventory() -): Promise { +async function releaseBrowserClientGuests(executor: ReleasableGuests): Promise { const results = await Promise.allSettled( - pages.map((page) => executor.retirePage(page.browserPageId, page.pageHostGeneration)) + executor + .snapshotPageInventory() + .map((page) => executor.retirePage(page.browserPageId, page.pageHostGeneration)) ) const failures = results.flatMap((result) => result.status === 'rejected' ? [result.reason] : [] diff --git a/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts b/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts index d59900960d42..651c27e9ead1 100644 --- a/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts +++ b/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts @@ -292,7 +292,7 @@ test('a host that quits and stays down past the grace gets the tab back when it ) }) -test('an older host that cannot rekey a kept page gets it back by reloading it', async ({ +test('an older host that cannot take back a kept page fails the outage as it always did', async ({ testRepoPath }, testInfo) => { test.setTimeout(300_000) @@ -302,22 +302,18 @@ test('an older host that cannot rekey a kept page gets it back by reloading it', { name: 'older host', hostEnv: { ORCA_E2E_DISABLE_RETURNING_HOST_RECLAIM: '1' } }, async (rig) => { await stampGuest(rig) - const generationBefore = await readPageHostGeneration(rig.client, rig.opened.localPageId) rig.proxy!.cut('reset') await new Promise((resolve) => setTimeout(resolve, OUTAGE_MS.reset)) rig.proxy!.restore() - await waitForNewPlacement(rig, generationBefore) - expect( - await waitForRenderedClientWebview( - rig.client.page, - { urlPrefix: rig.fixture.markerUrl, remotePageId: rig.opened.remotePageId }, - 'the reloaded page never rendered' - ) - ).toBe('outage-survivor') - expect(await readGuestStamp(rig), 'an older host can only reload the page').toBeNull() - await expectPageServing(rig) + // No regression and no fix: nothing parks, so the guest is closed exactly as before. + await expect + .poll(() => readGuestStamp(rig), { + timeout: 90_000, + message: 'an older host must not keep the guest' + }) + .toBeNull() } ) })