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/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-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-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..78db5a3a582f 100644 --- a/src/main/browser/browser-client-page-command-executor.ts +++ b/src/main/browser/browser-client-page-command-executor.ts @@ -107,19 +107,14 @@ export class BrowserClientPageCommandExecutor { case 'restorePage': return executeBrowserClientPageReconciliationCommand( { + routeWebContents: this.dependencies.routeWebContents, + routeSessions: this.dependencies.routeSessions, + retainNetworkRoute: this.dependencies.retainNetworkRoute, 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), - 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-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..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 @@ -17,7 +18,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,7 +30,11 @@ type BrowserClientPageReconciliationContext = { routeWebContents: BrowserClientPageLifecycleRegistry assertAvailable: () => void createPage: (event: BrowserClientHostCommandEvent, signal: AbortSignal) => Promise - navigate: (event: BrowserClientHostCommandEvent, signal: AbortSignal) => Promise + retainNetworkRoute( + executionHostKey: string, + signal: AbortSignal + ): Promise + routeSessions: Pick retirePage: (browserPageId: string, pageHostGeneration: number) => Promise cleanupPage: ( page: BrowserClientRetainedPage, @@ -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, @@ -176,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/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-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 baf75f868187..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' @@ -72,6 +73,31 @@ export function isRecoverableBrowserHostLeaseError(error: unknown): boolean { ) } +/** 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' + } +} + +/** 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 { return error instanceof Error ? error : new Error(String(error)) } 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..4bd0687daad6 --- /dev/null +++ b/src/main/browser/paired-runtime-browser-client-host-composition-contract.ts @@ -0,0 +1,73 @@ +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 + /** 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 01f26325f56c..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 @@ -4,7 +4,8 @@ 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 { 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' @@ -13,7 +14,19 @@ const authority: BrowserClientHostLeaseAuthority = { authorityEpoch: 'epoch-a', browserHostClientId: 'client-a', browserHostGeneration: 4, - pageCommandProtocolVersion: 1 + 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', + browserHostGeneration: 4, + pageCommandProtocolVersion: 1, + pageReconciliationProtocolVersion: 1 } const replacementAuthority: BrowserClientHostLeaseAuthority = { @@ -119,9 +132,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' }) @@ -267,7 +280,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) @@ -284,7 +297,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.isParked).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 +321,272 @@ 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.isParked).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 () => { + 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 = hostRefusal() + + rig.hostOptions.onError?.(fatal) + + expect(composition.isParked).toBe(false) + expect(rig.onError).toHaveBeenCalledWith(fatal) + await composition.whenClosed() + }) + + 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?.(new Error('Stale browser host page command')) + + 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() + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + + 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() + expect(rig.order.slice(3)).toEqual([ + 'retire-routes', + 'fence-authority-transition', + 'close-host', + 'complete-authority-transition', + 'activate-routes' + ]) + }) + + it('swaps a live lease for a fresh one without parking, as a dark route asks', async () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + + await composition.reattach() + + 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() + const rig = createRig({ + parkedGuestDiscardMs: 60_000 + }) const composition = rig.createComposition() await composition.start() - const replaced = authorityReplacedError() - - rig.hostOptions.onError?.(replaced) - vi.advanceTimersByTime(1_000) - - expect(rig.onError).toHaveBeenCalledWith(replaced) - expect(rig.order).toContain('closing') - await vi.runAllTimersAsync() - await composition.whenClosed() + 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('cancels the grace when the replacement authority actually arrives', 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('stays parked when a re-attach fails, and the next trigger tries again', 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') + + 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.isParked).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.replaceAuthority(replacementInput) + vi.advanceTimersByTime(59_999) + expect(rig.executor.retirePage).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) - // 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() + expect(rig.executor.retirePage).toHaveBeenCalledWith('page-a', 7) + expect(composition.isParked).toBe(true) + expect(rig.executor.close).not.toHaveBeenCalled() + await composition.close() } finally { vi.useRealTimers() } }) - it('cancels the grace when the composition closes for another reason', async () => { + 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() + const lost = contactLostError() + + rig.hostOptions.onError?.(lost) + + 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 () => { + const rig = createRig() + const composition = rig.createComposition() + await composition.start() + rig.hostOptions.onError?.(contactLostError()) + const refusal = hostRefusal() + 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() + const rig = createRig({ parkedGuestDiscardMs: 60_000 }) const composition = rig.createComposition() await composition.start() - rig.hostOptions.onError?.(authorityReplacedError()) + rig.hostOptions.onError?.(contactLostError()) + vi.advanceTimersByTime(50_000) + rig.failNextStart(unreachableError()) - await composition.close() + await expect(composition.resume()).rejects.toThrow('remote runtime unavailable') + 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) + 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 + }) 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.isParked).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 +665,13 @@ 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 startGates: Promise[] = [] const order: string[] = [] let onPageUnavailable = (_browserPageId: string, _pageHostGeneration: number): void => {} let settleHandlers = (): void => {} @@ -558,11 +746,17 @@ 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 inventory = callbacks.getPageInventory?.() ?? [] + await startGates.shift() const nextAuthority = replacement ? (options.replacementAuthority ?? replacementAuthority) - : authority + : initialAuthority if (replacement) { - replacementInventory = callbacks.getPageInventory?.() ?? [] + replacementInventory = inventory order.push('attach-replacement-inventory') } callbacks.onAuthority?.(nextAuthority) @@ -584,9 +778,16 @@ function createRig( }) }) const onError = vi.fn() + const onParkedChange = vi.fn() return { order, - authorityReplacementWait, + onParkedChange, + failNextStart: (error: Error) => startFailures.push(error), + holdNextStart: () => { + const gate = deferred() + startGates.push(gate.promise) + return gate + }, routes, replacementRoutes, executor, @@ -610,7 +811,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 +830,31 @@ function createRig( onClosing: () => { order.push('closing') }, - createAuthorityReplacementWait: () => authorityReplacementWait, - onError + ...(options.parkedGuestDiscardMs + ? { parkedGuestDiscardMs: options.parkedGuestDiscardMs } + : {}), + onError, + onParkedChange }) } } +function contactLostError(): Error { + return new BrowserHostLeaseContactLostError( + new Error('Browser host lease reconnect grace expired.') + ) +} + +function hostRefusal(): Error { + return new BrowserHostAnswerError('unauthorized', 'pairing revoked') +} + +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 4dfcada6068d..a9879885e77c 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,20 @@ 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 { browserHostRefusal, isFinalBrowserHostRefusal } from './browser-host-admission-recovery' import { asCompositionError, - closeBrowserClientHostComposition + closeBrowserClientHostComposition, + scheduleParkedGuestDiscard, + warnParkedCleanup } 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 +33,14 @@ export class PairedRuntimeBrowserClientHostComposition< private closed = false private errorReported = false private inventoryRefreshPromise: Promise | null = null - private readonly authorityReplacementWait: BrowserClientHostAuthorityReplacementWait + private input: Start + /** 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 constructor(private readonly options: PairedRuntimeBrowserClientHostCompositionOptions) { - this.authorityReplacementWait = - options.createAuthorityReplacementWait?.() ?? new BrowserClientHostAuthorityReplacementWait() + this.input = options.initialInput this.routeSets = new PairedRuntimeBrowserClientHostRouteSets({ createRoutes: options.createRoutes, onRecoveryError: (error) => this.handleHostError(error), @@ -116,23 +61,50 @@ export class PairedRuntimeBrowserClientHostComposition< return this.startPromise } + get isParked(): boolean { + return this.parked !== null + } + + get canPark(): boolean { + return this.reclaimable + } + replaceAuthority(input: Start): Promise { + this.input = input + return this.reattach() + } + + /** Swaps the lease for a fresh one under the current input, keeping every guest. */ + reattach(): 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.swapLease() return this.startPromise } + /** One re-attach for a parked composition; a live one answers with its current lease. */ + resume(): Promise { + return this.parked ? this.reattach() : this.start() + } + + /** Holds the pages for the runtime's return; only `close` gives them up. */ + private park(error: Error): void { + if (!this.parked) { + this.parked = { discardTimer: null } + this.options.onParkedChange?.(true, error) + } + 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 { if (this.closed) { throw new Error('paired_runtime_browser_client_host_composition_closed') @@ -155,7 +127,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.unpark() this.hostGeneration += 1 try { this.options.onClosing?.() @@ -195,9 +167,13 @@ export class PairedRuntimeBrowserClientHostComposition< publishedInventory?.length && authority.pageReconciliationProtocolVersion !== 1 ) { - throw new Error('browser_client_page_reconciliation_unsupported') + throw browserHostRefusal( + 'browser_client_page_reconciliation_unsupported', + 'The runtime cannot take back pages this desktop kept.' + ) } this.routeSets.activate(input, authority) + this.reclaimable = authority.returningHostReclaimProtocolVersion === 1 } }, onTransportLost: (error) => { @@ -210,42 +186,80 @@ 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) - return - } - this.authorityReplacementWait.arm(fatal) + this.parkOrFail(error) } }) } - private async finishAuthorityReplacement( - input: Start, - error: Error - ): Promise { - const previousHost = this.host - const settled = await previousHost.close(error) - if (!settled) { - await previousHost.whenHandlersSettled() + /** + * 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 swapLease(): Promise { + const input = this.input + this.clearGuestDiscard() + const error = new Error('Browser client host is re-attaching') + 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() + } + } finally { + this.executor.completeAuthorityTransition(input) } if (this.closed) { throw new Error('paired_runtime_browser_client_host_composition_closed') } - this.executor.completeAuthorityTransition(input) - const replacementHost = this.createHost(input, true) - this.host = replacementHost - return replacementHost.start() + try { + this.host = this.createHost(input, true) + const generation = this.hostGeneration + const authority = await this.host.start() + if (generation !== this.hostGeneration) { + throw new Error('browser_client_host_reattach_superseded') + } + this.unpark() + return authority + } catch (attachError) { + this.parkOrFail(asCompositionError(attachError)) + throw attachError + } + } + + /** + * 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 { + const waits = + isBrowserClientHostAuthorityReplaced(error) || + (this.reclaimable && !isFinalBrowserHostRefusal(error)) + if (this.closed || !waits) { + this.handleHostError(error) + } else { + this.park(error) + } + } + + private unpark(): void { + this.clearGuestDiscard() + if (this.parked) { + this.parked = null + this.options.onParkedChange?.(false) + } + } + + private clearGuestDiscard(): void { + if (this.parked?.discardTimer) { + clearTimeout(this.parked.discardTimer) + this.parked.discardTimer = null + } } private async handleCommand( @@ -264,7 +278,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.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 a1cd4df5bad7..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 @@ -80,6 +80,64 @@ describe('PairedRuntimeBrowserClientHostRegistry', () => { expect(replacement.start).toHaveBeenCalledOnce() }) + 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)) + + 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.isParked = 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('re-attaches only the named environment, and only while it is parked', 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.isParked = true + + await registry.resume('environment-a') + await registry.resume('environment-b') + await registry.resume('environment-missing') + + 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 () => { const cleanup = deferred() const first = createComposition(undefined, undefined, undefined, false, cleanup.promise) @@ -169,12 +227,15 @@ function createComposition( closed = Promise.resolve() ) { return { + isParked: false, start: vi.fn(async () => { if (order && startLabel) { order.push(startLabel) } 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 c34ee1a5cd99..8985a02072d2 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 isParked: boolean + resume(): Promise + reattach(): Promise retirePage(browserPageId: string, pageHostGeneration: number): Promise close(error?: Error): Promise whenClosed(): Promise @@ -54,7 +58,7 @@ export class PairedRuntimeBrowserClientHostRegistry< existing?.pairingRevision === input.pairingRevision && existing.authorityRuntimeId === input.authorityRuntimeId ) { - return existing.authority + return (existing.authority = existing.composition.resume()) } if (existing?.pairingRevision === input.pairingRevision) { existing.authorityRuntimeId = input.authorityRuntimeId @@ -62,7 +66,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.isParked) { + await this.cleanupFailedStart(input.environmentId, existing, error) + } throw error } } @@ -116,6 +123,30 @@ 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 || record.cleanupPending || !applies(record.composition)) { + return + } + record.authority = record.composition.reattach() + await record.authority.catch(() => undefined) + }) + } + 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..4647f78a58ad 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 @@ -83,11 +84,20 @@ const browserClientHosts = downloadRelay, (params) => executor?.recordPublishedPageUrl(params) ) - return new PairedRuntimeBrowserClientHostComposition({ + const composition = new PairedRuntimeBrowserClientHostComposition({ onClosing: routes.release, + parkedGuestDiscardMs: e2eParkedGuestDiscardMs(), + 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, 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, 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, @@ -123,6 +133,7 @@ const browserClientHosts = handler, getPageInventory, pageReconciliationProtocolVersion: 1, + returningHostReclaimProtocolVersion: 1, fileChannelProtocolVersion: 1, onAuthority, onTransportLost, @@ -135,6 +146,7 @@ const browserClientHosts = }, onError: (error) => retireFailedEnvironmentHost(input.environmentId, error) }) + return composition } }) @@ -229,6 +241,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() @@ -238,7 +255,7 @@ function createNetworkRoutes( pairing: PairingOffer, authority: BrowserClientHostLeaseAuthority, authorityStorageKey: string, - environmentId: string + onUnavailable: (error: Error) => void ): BrowserClientNetworkRouteRegistry { return new BrowserClientNetworkRouteRegistry({ authority, @@ -250,9 +267,7 @@ 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) + onUnavailable }) }) } @@ -310,6 +325,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-teardown.ts b/src/main/browser/paired-runtime-browser-client-host-teardown.ts index 93cbc95ab3c2..fc6977536518 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. */ +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/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..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,6 +13,7 @@ import { type RemoteRuntimeSubscriptionCallbacks } from '../../shared/remote-runtime-client' import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error' +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' @@ -155,7 +156,9 @@ export class PairedRuntimeBrowserHostLeaseConnection { return } if (!response.ok) { - this.fail(new RemoteRuntimeClientError(response.error.code, response.error.message)) + this.fail( + new BrowserHostAnswerError(response.error.code, response.error.message, response.error.data) + ) return } const parsed = BrowserClientHostEvent.safeParse(response.result) @@ -206,6 +209,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 +294,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..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/main/runtime/browser-host-client-page-adoption.ts b/src/main/runtime/browser-host-client-page-adoption.ts index 8a5c13868403..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,19 +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 + options: BrowserHostPageAdoptionOptions ): Promise } placements: { getPlacement(browserPageId: string): RuntimeBrowserPlacement | undefined } @@ -134,10 +129,10 @@ export type BrowserHostClientPageAdoptionDependencies = { */ export async function adoptBrowserHostClientPages( intents: readonly BrowserHostRuntimePageIntent[], - options: ReconciliationOptions, + options: BrowserHostPageAdoptionOptions, dependencies: BrowserHostClientPageAdoptionDependencies ): Promise { - if (intents.length === 0) { + if (intents.length === 0 && !options.releasedPageIds?.length) { return [] } const grants = intents.map((intent) => ({ diff --git a/src/main/runtime/browser-host-lease-registry.ts b/src/main/runtime/browser-host-lease-registry.ts index 07151bf83a51..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,7 +227,7 @@ export class BrowserHostLeaseRegistry { adoptClientPages( identity: BrowserHostLeaseIdentity, intents: readonly BrowserHostRuntimePageIntent[], - options: { maxConcurrency?: number; actionTimeoutMs?: number; signal?: AbortSignal } = {} + options: BrowserHostPageAdoptionOptions = {} ): Promise { return adoptBrowserHostClientPages(intents, options, { state: this.requireLeaseState(identity), @@ -234,6 +237,11 @@ export class BrowserHostLeaseRegistry { }) } + /** The lowest page generation a new placement may still take. */ + nextPageHostGeneration(): number { + return this.pagePlacements.nextPageHostGeneration + } + attachCommandDelivery( identity: BrowserHostLeaseIdentity, delivery: (event: BrowserClientHostCommandEvent) => void 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..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,9 @@ export class BrowserHostPageReconciliationOrchestrator { adopt( state: BrowserHostLeaseState, intents: readonly BrowserHostRuntimePageIntent[], - options: BrowserHostPageReconciliationOptions = {} + { releasedPageIds = [], ...options }: BrowserHostPageAdoptionOptions = {} ): 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.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/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..c9156f2efd7b 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,14 @@ 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 } : {}), + releasedPageIds: 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 +211,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..7cc251bafe77 --- /dev/null +++ b/src/main/runtime/runtime-browser-client-page-returning-host.test.ts @@ -0,0 +1,232 @@ +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('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, [ + 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/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/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..fbba92522ba8 --- /dev/null +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +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 { + 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 resumeBrowserClientHost = vi.fn(async () => false) + +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() + resetBrowserClientHostParkedForTests() + retryControlConnection.mockClear() + resumeBrowserClientHost.mockClear() + installClientHostedPaneApi({ + browser: { readClientHostId: () => 'this-desktop' }, + runtimeEnvironments: { retryControlConnection, 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 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(retryControlConnection).toHaveBeenCalledWith({ selector: 'env-a' }) + 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', () => { + 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 only its own environment to re-attach when the tab is opened', () => { + setHostOffline(false) + renderNotice({ isActive: true }) + + expect(resumeBrowserClientHost).toHaveBeenCalledWith({ selector: 'env-a' }) + 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 fbd82cc7fdcc..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 @@ -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 { + requestClientHostedBrowserReconnect, + 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. */ +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..e38213940250 --- /dev/null +++ b/src/renderer/src/components/browser-pane/use-client-hosted-browser-host-reachability.ts @@ -0,0 +1,48 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { runtimeHostConnectionStateForEntry } from '@/runtime/runtime-host-connection-state' +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 resumeBrowserClientHost(runtimeEnvironmentId) +} + +/** + * 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 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 controlOffline = 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) { + void resumeBrowserClientHost(runtimeEnvironmentId) + } + }, [isActive, runtimeEnvironmentId]) + return { hostOffline: parked || controlOffline, 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}}", 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/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..8197dc8b88ae 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' @@ -181,6 +181,7 @@ export const createRuntimeStatusSlice: StateCreator { 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({ 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/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/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 new file mode 100644 index 000000000000..651c27e9ead1 --- /dev/null +++ b/tests/e2e/paired-client-hosted-browser-network-outage.spec.ts @@ -0,0 +1,398 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +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 { + navigateGuest, + readClientBrowserRows, + openClientHostedFixturePage, + readClientWebviewMarker, + selectPairedWorktreeGroup, + startClientHostedMarkerFixture, + waitForPairedWorktreeId, + 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' + +/** + * 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 +/** 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, + localPageId: string +): Promise { + return client.page.evaluate((pageId) => { + const placement = window.__store?.getState().remoteBrowserPageHandlesByPageId[pageId]?.placement + return placement?.kind === 'client' ? placement.pageHostGeneration : null + }, localPageId) +} + +/** 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)}` + ) + ).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) +} + +for (const mode of ['silent', 'reset'] as const) { + test(`the same live page survives a ${mode} network outage longer than the lease grace`, async ({ + testRepoPath + }, testInfo) => { + test.setTimeout(420_000) + 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) + } + 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( + rig.client.page, + { urlPrefix: rig.fixture.markerUrl, remotePageId: rig.opened.remotePageId }, + 'the page never came back after its host relaunched' + ) + ).toBe('outage-survivor') + await expectPageServing(rig) + } + ) +}) + +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) + await withOutageRig( + testInfo, + testRepoPath, + { name: 'older host', hostEnv: { ORCA_E2E_DISABLE_RETURNING_HOST_RECLAIM: '1' } }, + async (rig) => { + await stampGuest(rig) + + rig.proxy!.cut('reset') + await new Promise((resolve) => setTimeout(resolve, OUTAGE_MS.reset)) + rig.proxy!.restore() + + // 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() + } + ) +}) + +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 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) +}