mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(browser): switch client-hosted profiles by staging the replacement guest (#30452)
* fix(browser): switch client-hosted profiles with staged guest replacement Squashed from #30351 by @oliver-mee onto the current main. Fixes #30350 * fix(browser): fence staged profile guests, release switch grants, carry host profile metadata - A fence during automation retirement now revokes and stops the staged guest. - A failed or late-settling switch releases its execution-host grant so the page can be recreated. - Staging cleanup failures before handover stay visible to executor close. - createPage/restorePage carry host-owned profile metadata to profile-switch-v1 desktops, so recovery and restore can rebuild a switched page. * fix(browser): roll back a switch whose replacement died, and report a lost old guest * fix(browser): resolve client-hosted pages before the server-local target path when switching profiles * fix(browser): a staged profile-switch guest owns downloads it starts while loading * fix(browser): keep the original placement until a timed-out switch settles * fix(browser): rebind the original guest before releasing a rolled-back staged guest * test(browser): type the harness guest release mock * fix(browser): release the replaced generation's download transfers when a switch commits * fix(browser): admit file transfers from a staged profile-switch guest while its switch is pending * fix(browser): release only the replaced generation's download transfers after a switch * refactor(shared): import BrowserNetworkExecutionHost from its own module, no re-export * fix(browser): adopt a profile switch the client finished after the caller timed out --------- Co-authored-by: Oliver Mee <102673257+oliver-mee@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,16 @@ export function selectCommandPage(
|
||||
}
|
||||
}
|
||||
if (command.pageHostGeneration < page.generation) {
|
||||
const previous = page.records.get(command.commandSequence)
|
||||
if (
|
||||
command.command.type === 'switchProfile' &&
|
||||
previous?.result?.status === 'completed' &&
|
||||
previous.event.pageHostGeneration === command.pageHostGeneration &&
|
||||
previous.event.commandId === command.commandId &&
|
||||
commandsMatch(previous.event, command)
|
||||
) {
|
||||
return { page, commit: () => {} }
|
||||
}
|
||||
throw new Error('browser_host_page_generation_stale')
|
||||
}
|
||||
if (command.pageHostGeneration === page.generation) {
|
||||
@@ -88,6 +98,12 @@ function commandsMatch(
|
||||
if (first.command.type !== second.command.type) {
|
||||
return false
|
||||
}
|
||||
if (first.command.type === 'switchProfile' && second.command.type === 'switchProfile') {
|
||||
return (
|
||||
first.command.nextPageHostGeneration === second.command.nextPageHostGeneration &&
|
||||
JSON.stringify(first.command.profile) === JSON.stringify(second.command.profile)
|
||||
)
|
||||
}
|
||||
if (first.command.type === 'navigate' && second.command.type === 'navigate') {
|
||||
return first.command.url === second.command.url
|
||||
}
|
||||
@@ -121,6 +137,13 @@ export function assertNewPageCommand(
|
||||
page: PageState,
|
||||
command: BrowserClientHostCommandEvent
|
||||
): void {
|
||||
const active = [...page.records.values()].filter((record) => record.status !== 'settled')
|
||||
if (
|
||||
active.some((record) => record.event.command.type === 'switchProfile') ||
|
||||
(command.command.type === 'switchProfile' && active.length > 0)
|
||||
) {
|
||||
throw new Error('browser_host_profile_switch_pending')
|
||||
}
|
||||
if (page.nextSequence === 1 && command.command.type === 'navigate') {
|
||||
throw new Error('browser_host_command_create_required')
|
||||
}
|
||||
@@ -169,6 +192,9 @@ export function recordBrowserClientPageCommandResult(
|
||||
command: BrowserClientHostCommandEvent['command'],
|
||||
result: BrowserClientHostCommandResult
|
||||
): void {
|
||||
if (command.type === 'switchProfile' && result.status === 'completed') {
|
||||
page.generation = command.nextPageHostGeneration
|
||||
}
|
||||
if (isBrowserClientPageBootstrapCommand(command)) {
|
||||
page.created = result.status === 'completed'
|
||||
page.createFailed = !page.created
|
||||
|
||||
@@ -124,6 +124,9 @@ export function createPageState(browserPageId: string, generation: number): Page
|
||||
function snapshotPageCommand(
|
||||
command: BrowserClientHostCommandEvent['command']
|
||||
): BrowserClientHostCommandEvent['command'] {
|
||||
if (command.type === 'switchProfile') {
|
||||
return Object.freeze({ ...command, profile: Object.freeze({ ...command.profile }) })
|
||||
}
|
||||
if (command.type === 'reclaimPage') {
|
||||
return Object.freeze({
|
||||
...command,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
BrowserHostLeaseAuthority,
|
||||
BrowserNetworkExecutionHost
|
||||
} from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserHostLeaseAuthority } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import type { BrowserClientPageNetworkRoute } from './browser-client-page-cleanup'
|
||||
import {
|
||||
assertBrowserClientNetworkRouteAddress,
|
||||
|
||||
@@ -68,6 +68,22 @@ describe('BrowserClientPageAutomationRuntime', () => {
|
||||
expect(fixture.unregisterGuest).toHaveBeenCalledWith('page-a')
|
||||
})
|
||||
|
||||
it('retires an old automation session without stripping the guest needed by rollback', async () => {
|
||||
const fixture = createFixture()
|
||||
await fixture.runtime.execute(input(), new AbortController().signal)
|
||||
await fixture.runtime.retire({
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: 9,
|
||||
registration,
|
||||
keepGuestBinding: true
|
||||
})
|
||||
expect(fixture.onTabClosed).toHaveBeenCalledWith(42)
|
||||
expect(fixture.unregisterGuest).not.toHaveBeenCalled()
|
||||
await expect(fixture.runtime.execute(input(), new AbortController().signal)).resolves.toEqual({
|
||||
clicked: true
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects aborts and registration failures before RPC dispatch', async () => {
|
||||
const fixture = createFixture()
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -16,7 +16,7 @@ type AutomationPageInput = {
|
||||
type AutomationPageRetirement = Pick<
|
||||
AutomationPageInput,
|
||||
'browserPageId' | 'pageHostGeneration' | 'registration'
|
||||
>
|
||||
> & { keepGuestBinding?: boolean }
|
||||
|
||||
type AutomationRuntimeDependencies = {
|
||||
browserManager: Pick<
|
||||
@@ -68,8 +68,9 @@ export class BrowserClientPageAutomationRuntime {
|
||||
await bridge.onTabClosed(current.webContentsId)
|
||||
}
|
||||
if (
|
||||
!input.keepGuestBinding &&
|
||||
this.dependencies.browserManager.getGuestWebContentsId(input.browserPageId) ===
|
||||
current.webContentsId
|
||||
current.webContentsId
|
||||
) {
|
||||
this.dependencies.browserManager.unregisterGuest(input.browserPageId)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BrowserClientPageCommandExecutorDependencies } from './browser-client-page-command-executor-dependencies'
|
||||
import type { BrowserRoutePageGuestIdentity } from './browser-route-page-authority'
|
||||
import type { BrowserClientRetainedPage } from './browser-client-page-retained-state'
|
||||
|
||||
@@ -22,3 +23,23 @@ export function markBrowserClientPageUnavailable(
|
||||
onUnavailable(registration.browserPageId, page.generation)
|
||||
return true
|
||||
}
|
||||
|
||||
export function publishBrowserClientRetainedPage(
|
||||
dependencies: BrowserClientPageCommandExecutorDependencies,
|
||||
pages: Map<string, BrowserClientRetainedPage>,
|
||||
page: BrowserClientRetainedPage
|
||||
): void {
|
||||
const previous = pages.get(page.inventory.browserPageId)
|
||||
previous?.releaseAvailabilityWatch?.()
|
||||
if (previous) {
|
||||
previous.releaseAvailabilityWatch = undefined
|
||||
}
|
||||
page.releaseAvailabilityWatch = dependencies.routeWebContents.watchPageAvailability?.(
|
||||
page.inventory.browserPageId,
|
||||
(registration) =>
|
||||
markBrowserClientPageUnavailable(pages, registration, (browserPageId, generation) =>
|
||||
dependencies.onPageUnavailable?.(browserPageId, generation)
|
||||
)
|
||||
)
|
||||
pages.set(page.inventory.browserPageId, page)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ export type BrowserClientPageCommandExecutorDependencies = BrowserClientPageAuth
|
||||
signal: AbortSignal
|
||||
): Promise<unknown>
|
||||
retireAutomation(input: {
|
||||
keepGuestBinding?: boolean
|
||||
browserPageId: string
|
||||
pageHostGeneration: number
|
||||
registration: BrowserClientRetainedPage['registration']
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BrowserRoutePreparePageInput } from './browser-route-session-state'
|
||||
import type { BrowserClientHostCommandEvent } from '../../shared/browser-client-host-protocol'
|
||||
import { vi } from 'vitest'
|
||||
import { BrowserClientPageCommandExecutor } from './browser-client-page-command-executor'
|
||||
@@ -89,7 +90,7 @@ export function createHarness(options: { maxPages?: number } = {}) {
|
||||
}),
|
||||
selectRenderer: vi.fn(() => renderer),
|
||||
routeSessions: {
|
||||
preparePage: vi.fn(async () => {
|
||||
preparePage: vi.fn(async (_input: BrowserRoutePreparePageInput) => {
|
||||
order.push('prepare-page')
|
||||
return routeSession
|
||||
})
|
||||
@@ -100,7 +101,7 @@ export function createHarness(options: { maxPages?: number } = {}) {
|
||||
bind: vi.fn(() => {
|
||||
order.push('bind-guest')
|
||||
}),
|
||||
release: vi.fn(() => {
|
||||
release: vi.fn((_registration: BrowserRoutePageGuestIdentity) => {
|
||||
order.push('release-guest')
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import { closeBrowserClientExecutorPages } from './browser-client-page-executor-close'
|
||||
import {
|
||||
BrowserClientProfileSwitches,
|
||||
switchBrowserClientPageProfile
|
||||
} from './browser-client-page-profile-switch'
|
||||
import {
|
||||
BROWSER_CLIENT_HOST_PAGE_INVENTORY_MAX_PAGES,
|
||||
type BrowserClientHostedPageInventory,
|
||||
@@ -20,7 +25,7 @@ import { BrowserClientPageNavigationFence } from './browser-client-page-navigati
|
||||
import { executeBrowserClientPageReconciliationCommand } from './browser-client-page-reconciliation'
|
||||
import {
|
||||
findBrowserClientPageByWebContentsId,
|
||||
forgetBrowserClientRetainedPage,
|
||||
retireBrowserClientRetainedPage,
|
||||
type BrowserClientRetainedPage
|
||||
} from './browser-client-page-retained-state'
|
||||
import {
|
||||
@@ -32,7 +37,7 @@ import {
|
||||
executeBrowserClientPageAutomationCommand,
|
||||
navigateBrowserClientPageCommand
|
||||
} from './browser-client-page-command-execution'
|
||||
import { markBrowserClientPageUnavailable } from './browser-client-page-availability'
|
||||
import { publishBrowserClientRetainedPage } from './browser-client-page-availability'
|
||||
import type {
|
||||
BrowserClientPageAuthorityIdentity,
|
||||
BrowserClientPageCommandExecutorDependencies
|
||||
@@ -43,6 +48,7 @@ export class BrowserClientPageCommandExecutor {
|
||||
private readonly pages = new Map<string, BrowserClientRetainedPage>()
|
||||
private readonly creatingPages = new Map<string, BrowserClientHostedPageInventory>()
|
||||
private readonly failedPages = new Map<string, BrowserClientHostedPageInventory>()
|
||||
private readonly profileSwitches = new BrowserClientProfileSwitches()
|
||||
private readonly navigationFence = new BrowserClientPageNavigationFence()
|
||||
private closePromise: Promise<void> | null = null
|
||||
private authorityConnectionIdentity: string
|
||||
@@ -68,13 +74,19 @@ export class BrowserClientPageCommandExecutor {
|
||||
signal: AbortSignal
|
||||
): Promise<BrowserClientHostCommandResult> {
|
||||
if (this.closed || this.authorityTransitioning || this.navigationFence.isFenced) {
|
||||
return { status: 'failed', errorCode: 'browser_client_page_executor_closed' }
|
||||
return {
|
||||
status: 'failed',
|
||||
errorCode: 'browser_client_page_executor_closed'
|
||||
}
|
||||
}
|
||||
try {
|
||||
const value = await this.executeCommand(event, signal)
|
||||
return value === undefined ? { status: 'completed' } : { status: 'completed', value }
|
||||
} catch (error) {
|
||||
return { status: 'failed', errorCode: browserClientPageCommandFailureCode(error, signal) }
|
||||
return {
|
||||
status: 'failed',
|
||||
errorCode: browserClientPageCommandFailureCode(error, signal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +95,22 @@ export class BrowserClientPageCommandExecutor {
|
||||
signal: AbortSignal
|
||||
): Promise<unknown> {
|
||||
switch (event.command.type) {
|
||||
case 'switchProfile':
|
||||
return switchBrowserClientPageProfile({
|
||||
event,
|
||||
signal,
|
||||
switches: this.profileSwitches,
|
||||
pages: this.pages,
|
||||
dependencies: {
|
||||
...this.dependencies,
|
||||
authorityConnectionIdentity: this.authorityConnectionIdentity,
|
||||
legacyAuthorityConnectionIdentity: this.legacyAuthorityConnectionIdentity
|
||||
},
|
||||
assertAvailable: () =>
|
||||
this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning),
|
||||
commit: (page) => publishBrowserClientRetainedPage(this.dependencies, this.pages, page),
|
||||
cleanup: (page, releaseUploads) => this.cleanupPage(page, undefined, releaseUploads)
|
||||
})
|
||||
case 'createPage':
|
||||
return this.createPage(event, signal)
|
||||
case 'navigate':
|
||||
@@ -157,14 +185,14 @@ export class BrowserClientPageCommandExecutor {
|
||||
close(): Promise<void> {
|
||||
this.closed = true
|
||||
return (this.closePromise ??= this.navigationFence.fenceBeforeCleanup(
|
||||
this.pages.values(),
|
||||
this.profileSwitches.withStagedPages(this.pages.values()),
|
||||
(claim) => this.dependencies.routeWebContents.revokeNavigation(claim),
|
||||
() => this.closePages()
|
||||
))
|
||||
}
|
||||
|
||||
fenceNavigation(): void {
|
||||
this.navigationFence.fence(this.pages.values(), (claim) =>
|
||||
this.navigationFence.fence(this.profileSwitches.withStagedPages(this.pages.values()), (claim) =>
|
||||
this.dependencies.routeWebContents.revokeNavigation(claim)
|
||||
)
|
||||
}
|
||||
@@ -174,8 +202,9 @@ export class BrowserClientPageCommandExecutor {
|
||||
throw new Error('browser_client_page_authority_transition_unavailable')
|
||||
}
|
||||
this.authorityTransitioning = true
|
||||
this.navigationFence.revoke(this.pages.values(), (claim) =>
|
||||
this.dependencies.routeWebContents.revokeNavigation(claim)
|
||||
this.navigationFence.revoke(
|
||||
this.profileSwitches.withStagedPages(this.pages.values()),
|
||||
(claim) => this.dependencies.routeWebContents.revokeNavigation(claim)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,24 +217,14 @@ export class BrowserClientPageCommandExecutor {
|
||||
this.authorityTransitioning = false
|
||||
}
|
||||
|
||||
async retirePage(browserPageId: string, pageHostGeneration: number): Promise<boolean> {
|
||||
const page = this.pages.get(browserPageId)
|
||||
if (!page || page.generation !== pageHostGeneration) {
|
||||
return false
|
||||
}
|
||||
page.retiring ??= this.cleanupPage(page)
|
||||
try {
|
||||
await page.retiring
|
||||
} catch (error) {
|
||||
forgetBrowserClientRetainedPage(this.pages, browserPageId, page)
|
||||
this.failedPages.set(
|
||||
browserPageId,
|
||||
Object.freeze({ ...page.inventory, state: 'outcomeUnknown' })
|
||||
)
|
||||
throw error
|
||||
}
|
||||
forgetBrowserClientRetainedPage(this.pages, browserPageId, page)
|
||||
return true
|
||||
retirePage(browserPageId: string, pageHostGeneration: number): Promise<boolean> {
|
||||
return retireBrowserClientRetainedPage(
|
||||
this.pages,
|
||||
this.failedPages,
|
||||
browserPageId,
|
||||
pageHostGeneration,
|
||||
(page) => this.cleanupPage(page)
|
||||
)
|
||||
}
|
||||
|
||||
private async createPage(
|
||||
@@ -252,29 +271,24 @@ export class BrowserClientPageCommandExecutor {
|
||||
signal,
|
||||
() => this.navigationFence.assertAvailable(this.closed || this.authorityTransitioning),
|
||||
(page) => {
|
||||
page.releaseAvailabilityWatch = this.dependencies.routeWebContents.watchPageAvailability?.(
|
||||
event.browserPageId,
|
||||
(registration) =>
|
||||
markBrowserClientPageUnavailable(
|
||||
this.pages,
|
||||
registration,
|
||||
(browserPageId, generation) =>
|
||||
this.dependencies.onPageUnavailable?.(browserPageId, generation)
|
||||
)
|
||||
)
|
||||
this.pages.set(event.browserPageId, page)
|
||||
publishBrowserClientRetainedPage(this.dependencies, this.pages, page)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the download relay only knows the guest WebContents, and staging is keyed by page identity.
|
||||
findPageByWebContentsId(webContentsId: number): BrowserClientHostedPageInventory | undefined {
|
||||
return findBrowserClientPageByWebContentsId(this.pages.values(), webContentsId)?.inventory
|
||||
// A staged profile-switch guest already owns its downloads while it loads the page.
|
||||
return findBrowserClientPageByWebContentsId(
|
||||
this.profileSwitches.withStagedPages(this.pages.values()),
|
||||
webContentsId
|
||||
)?.inventory
|
||||
}
|
||||
|
||||
private async cleanupPage(
|
||||
page: BrowserClientRetainedPage,
|
||||
previousRendererPage?: BrowserClientPageRendererIdentity
|
||||
previousRendererPage?: BrowserClientPageRendererIdentity,
|
||||
releaseUploads = true
|
||||
): Promise<void> {
|
||||
page.releaseAvailabilityWatch?.()
|
||||
page.releaseAvailabilityWatch = undefined
|
||||
@@ -285,34 +299,23 @@ export class BrowserClientPageCommandExecutor {
|
||||
routeWebContents: this.dependencies.routeWebContents,
|
||||
retireAutomation: this.dependencies.retireAutomation,
|
||||
releaseUploadStaging: async () => {
|
||||
await this.dependencies.uploadStaging?.releasePage(page.inventory.browserPageId)
|
||||
if (releaseUploads) {
|
||||
await this.dependencies.uploadStaging?.releasePage(page.inventory.browserPageId)
|
||||
}
|
||||
}
|
||||
},
|
||||
previousRendererPage
|
||||
)
|
||||
}
|
||||
|
||||
private async closePages(): Promise<void> {
|
||||
const failures: unknown[] = [
|
||||
...(this.creatingPages.size > 0
|
||||
? [new Error('browser_client_page_creation_still_running')]
|
||||
: []),
|
||||
...(this.failedPages.size > 0 ? [new Error('browser_client_page_cleanup_unresolved')] : [])
|
||||
]
|
||||
for (const [browserPageId, page] of this.pages) {
|
||||
try {
|
||||
page.retiring ??= this.cleanupPage(page)
|
||||
await page.retiring
|
||||
forgetBrowserClientRetainedPage(this.pages, browserPageId, page)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
await this.dependencies.uploadStaging?.releaseAll().catch((error: unknown) => {
|
||||
failures.push(error)
|
||||
private closePages(): Promise<void> {
|
||||
return closeBrowserClientExecutorPages({
|
||||
profileSwitches: this.profileSwitches,
|
||||
creatingPages: this.creatingPages,
|
||||
failedPages: this.failedPages,
|
||||
pages: this.pages,
|
||||
cleanupPage: (page) => this.cleanupPage(page),
|
||||
uploadStaging: this.dependencies.uploadStaging
|
||||
})
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Browser client page executor cleanup failed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function createReservedBrowserClientPage(
|
||||
renderer = dependencies.selectRenderer()
|
||||
assertCurrentBrowserClientPageRenderer(renderer)
|
||||
routeSession = await dependencies.routeSessions.preparePage({
|
||||
...(event.command.profile ? { profileMetadata: event.command.profile } : {}),
|
||||
identity: {
|
||||
orcaProfileId: dependencies.orcaProfileId,
|
||||
browserProfileId: event.command.browserProfileId,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { BrowserClientRetainedPage } from './browser-client-page-retained-state'
|
||||
import type { BrowserClientUploadStaging } from './browser-client-upload-staging'
|
||||
import { forgetBrowserClientRetainedPage } from './browser-client-page-retained-state'
|
||||
import type { BrowserClientProfileSwitches } from './browser-client-page-profile-switch'
|
||||
|
||||
export async function closeBrowserClientExecutorPages(input: {
|
||||
profileSwitches: Pick<BrowserClientProfileSwitches, 'retainedCleanups' | 'stagedPages'>
|
||||
creatingPages: ReadonlyMap<string, unknown>
|
||||
failedPages: ReadonlyMap<string, unknown>
|
||||
pages: Map<string, BrowserClientRetainedPage>
|
||||
cleanupPage(page: BrowserClientRetainedPage): Promise<void>
|
||||
uploadStaging?: BrowserClientUploadStaging
|
||||
}): Promise<void> {
|
||||
const retired = await Promise.allSettled(input.profileSwitches.retainedCleanups)
|
||||
const failures: unknown[] = [
|
||||
...retired.filter((result) => result.status === 'rejected').map((result) => result.reason),
|
||||
...(input.creatingPages.size > 0 || input.profileSwitches.stagedPages.size > 0
|
||||
? [new Error('browser_client_page_creation_still_running')]
|
||||
: []),
|
||||
...(input.failedPages.size > 0 ? [new Error('browser_client_page_cleanup_unresolved')] : [])
|
||||
]
|
||||
for (const [browserPageId, page] of input.pages) {
|
||||
try {
|
||||
page.retiring ??= input.cleanupPage(page)
|
||||
await page.retiring
|
||||
forgetBrowserClientRetainedPage(input.pages, browserPageId, page)
|
||||
} catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
await input.uploadStaging?.releaseAll().catch((error: unknown) => {
|
||||
failures.push(error)
|
||||
})
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures, 'Browser client page executor cleanup failed')
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type BrowserClientPageGuestBindingInput = {
|
||||
* the renderer at all.
|
||||
*/
|
||||
export type BrowserClientPageGuestBinding = {
|
||||
bindReplacement?(input: BrowserClientPageGuestBindingInput): void
|
||||
bind(input: BrowserClientPageGuestBindingInput): void
|
||||
release(registration: BrowserRoutePageGuestIdentity): void
|
||||
}
|
||||
@@ -24,6 +25,24 @@ export function createBrowserClientPageGuestBinding(
|
||||
>
|
||||
): BrowserClientPageGuestBinding {
|
||||
return {
|
||||
bindReplacement: ({ registration, browserProfileId }) => {
|
||||
const bound = browserManager.registerGuest(
|
||||
{
|
||||
browserPageId: registration.browserPageId,
|
||||
sessionProfileId: browserProfileId,
|
||||
webContentsId: registration.webContentsId,
|
||||
rendererWebContentsId: registration.rendererWebContentsId
|
||||
},
|
||||
{ retainPreviousGuestPolicies: true }
|
||||
)
|
||||
if (
|
||||
!bound ||
|
||||
browserManager.getGuestWebContentsId(registration.browserPageId) !==
|
||||
registration.webContentsId
|
||||
) {
|
||||
throw new Error('browser_client_page_guest_registration_failed')
|
||||
}
|
||||
},
|
||||
bind: ({ registration, browserProfileId }) => {
|
||||
if (
|
||||
browserManager.getGuestWebContentsId(registration.browserPageId) ===
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const browserMocks = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(() => '/downloads'),
|
||||
shellOpenExternalMock: vi.fn(),
|
||||
browserWindowFromWebContentsMock: vi.fn(),
|
||||
menuBuildFromTemplateMock: vi.fn(),
|
||||
guestOffMock: vi.fn(),
|
||||
guestOnMock: vi.fn(),
|
||||
guestSetBackgroundThrottlingMock: vi.fn(),
|
||||
guestSetWindowOpenHandlerMock: vi.fn(),
|
||||
guestOpenDevToolsMock: vi.fn(),
|
||||
webContentsFromIdMock: vi.fn(),
|
||||
screenGetCursorScreenPointMock: vi.fn(() => ({ x: 0, y: 0 })),
|
||||
openPopupWithOriginBarMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: browserMocks.appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: browserMocks.browserWindowFromWebContentsMock },
|
||||
clipboard: { writeText: vi.fn() },
|
||||
shell: { openExternal: browserMocks.shellOpenExternalMock },
|
||||
Menu: { buildFromTemplate: browserMocks.menuBuildFromTemplateMock },
|
||||
screen: { getCursorScreenPoint: browserMocks.screenGetCursorScreenPointMock },
|
||||
webContents: { fromId: browserMocks.webContentsFromIdMock }
|
||||
}))
|
||||
|
||||
vi.mock('./popup-origin-bar-window', () => ({
|
||||
openPopupWithOriginBar: browserMocks.openPopupWithOriginBarMock
|
||||
}))
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import { createBrowserClientPageGuestBinding } from './browser-client-page-guest-binding'
|
||||
import { resetBrowserManagerMocks, resetBrowserManagerState } from './browser-manager-test-harness'
|
||||
|
||||
const registration = (webContentsId: number, pageHostGeneration: number) => ({
|
||||
partition: 'persist:route',
|
||||
browserPageId: 'page-a',
|
||||
webContentsId,
|
||||
pageHostGeneration,
|
||||
rendererWebContentsId: 5001
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetBrowserManagerMocks(browserMocks)
|
||||
resetBrowserManagerState()
|
||||
})
|
||||
afterEach(() => resetBrowserManagerState())
|
||||
|
||||
describe('staged guest binding', () => {
|
||||
it('retains the old guest policy attachment so rollback can actually rebind it', () => {
|
||||
const guests = [createGuest(41), createGuest(42)]
|
||||
browserMocks.webContentsFromIdMock.mockImplementation(
|
||||
(id: number) => guests.find((guest) => guest.id === id) ?? null
|
||||
)
|
||||
for (const guest of guests) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Only the WebContents methods used by attachGuestPolicies are exercised by this fixture.
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
}
|
||||
const binding = createBrowserClientPageGuestBinding(browserManager)
|
||||
binding.bind({ registration: registration(41, 1), browserProfileId: 'old-profile' })
|
||||
binding.bindReplacement!({ registration: registration(42, 2), browserProfileId: 'new-profile' })
|
||||
expect(browserManager.getGuestWebContentsId('page-a')).toBe(42)
|
||||
binding.release(registration(42, 2))
|
||||
binding.bind({ registration: registration(41, 1), browserProfileId: 'old-profile' })
|
||||
expect(browserManager.getGuestWebContentsId('page-a')).toBe(41)
|
||||
expect(browserManager.getSessionProfileIdForTab('page-a')).toBe('old-profile')
|
||||
})
|
||||
})
|
||||
|
||||
function createGuest(id: number) {
|
||||
return {
|
||||
id,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: browserMocks.guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: browserMocks.guestSetWindowOpenHandlerMock,
|
||||
on: browserMocks.guestOnMock,
|
||||
off: browserMocks.guestOffMock,
|
||||
openDevTools: browserMocks.guestOpenDevToolsMock
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { BrowserRoutePageAvailability } from './browser-route-page-availability'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createCommand,
|
||||
createHarness,
|
||||
createLifecycleClaim
|
||||
} from './browser-client-page-command-executor-test-harness'
|
||||
import type { BrowserClientHostCommandEvent } from '../../shared/browser-client-host-protocol'
|
||||
|
||||
const switchCommand = (): BrowserClientHostCommandEvent =>
|
||||
createCommand('navigate', {
|
||||
pageReconciliationProtocolVersion: 1,
|
||||
command: {
|
||||
type: 'switchProfile',
|
||||
nextPageHostGeneration: 8,
|
||||
profile: {
|
||||
id: 'remote-isolated',
|
||||
label: 'Remote profile',
|
||||
scope: 'isolated'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function fixture(
|
||||
availability?: BrowserRoutePageAvailability,
|
||||
oldGuestIsCurrent?: () => boolean
|
||||
) {
|
||||
const harness = createHarness()
|
||||
if (oldGuestIsCurrent) {
|
||||
harness.dependencies.routeWebContents.claimGuestLifecycle.mockImplementationOnce(
|
||||
(registration) => createLifecycleClaim(registration, Promise.resolve(), oldGuestIsCurrent)
|
||||
)
|
||||
}
|
||||
if (availability) {
|
||||
Object.assign(harness.dependencies.routeWebContents, {
|
||||
watchPageAvailability: availability.watch
|
||||
})
|
||||
}
|
||||
const bindReplacement = vi.fn()
|
||||
Object.assign(harness.dependencies.guestBinding, { bindReplacement })
|
||||
await harness.executor.handle(createCommand('createPage'), new AbortController().signal)
|
||||
harness.dependencies.routeSessions.preparePage.mockResolvedValue({
|
||||
partition: `persist:orca-browser-v1-${'b'.repeat(64)}`,
|
||||
release: vi.fn()
|
||||
})
|
||||
harness.renderer.mountPage.mockResolvedValue({ webContentsId: 42 })
|
||||
harness.order.length = 0
|
||||
return { ...harness, bindReplacement }
|
||||
}
|
||||
|
||||
describe('staged client page profile switching', () => {
|
||||
it('prepares a separate partition and guest before retiring the authenticated old guest', async () => {
|
||||
const h = await fixture()
|
||||
let retired = false
|
||||
h.dependencies.routeWebContents.beginGuestRetirement.mockImplementation(() => {
|
||||
retired = true
|
||||
expect(h.bindReplacement).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registration: expect.objectContaining({
|
||||
webContentsId: 42,
|
||||
pageHostGeneration: 8
|
||||
})
|
||||
})
|
||||
)
|
||||
return Promise.resolve()
|
||||
})
|
||||
h.bindReplacement.mockImplementation(() => expect(retired).toBe(false))
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 8)).toBe(true)
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(false)
|
||||
expect(h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{ browserProfileId: 'remote-isolated', pageHostGeneration: 8 }
|
||||
])
|
||||
expect(h.dependencies.routeSessions.preparePage).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
identity: expect.objectContaining({
|
||||
browserProfileId: 'remote-isolated',
|
||||
authorityConnectionIdentity: 'authority-record-a'
|
||||
}),
|
||||
profileMetadata: {
|
||||
id: 'remote-isolated',
|
||||
label: 'Remote profile',
|
||||
scope: 'isolated'
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the old guest when target preparation fails', async () => {
|
||||
const h = await fixture()
|
||||
h.dependencies.routeSessions.preparePage.mockRejectedValueOnce(
|
||||
new Error('target profile rejected')
|
||||
)
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(h.dependencies.routeWebContents.beginGuestRetirement).not.toHaveBeenCalled()
|
||||
expect(h.bindReplacement).not.toHaveBeenCalled()
|
||||
expect(h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{ browserProfileId: 'profile-a', state: 'active' }
|
||||
])
|
||||
})
|
||||
|
||||
it('retires only the staged guest and restores the old binding when binding fails', async () => {
|
||||
const h = await fixture()
|
||||
h.bindReplacement.mockImplementation(() => {
|
||||
throw new Error('bind failed')
|
||||
})
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(h.dependencies.routeWebContents.beginGuestRetirement).toHaveBeenCalledTimes(1)
|
||||
expect(h.dependencies.routeWebContents.beginGuestRetirement).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registration: expect.objectContaining({ webContentsId: 42 })
|
||||
})
|
||||
)
|
||||
expect(h.dependencies.guestBinding.bind).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
registration: expect.objectContaining({ webContentsId: 41 }),
|
||||
browserProfileId: 'profile-a'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the old guest when replacement navigation fails', async () => {
|
||||
const h = await fixture()
|
||||
await h.executor.handle(
|
||||
createCommand('navigate', {
|
||||
command: { type: 'navigate', url: 'https://example.com/authenticated' }
|
||||
}),
|
||||
new AbortController().signal
|
||||
)
|
||||
h.dependencies.routeWebContents.navigateGuest.mockResolvedValueOnce(false)
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{
|
||||
browserProfileId: 'profile-a',
|
||||
currentUrl: 'https://example.com/authenticated',
|
||||
state: 'active'
|
||||
}
|
||||
])
|
||||
expect(h.dependencies.guestBinding.bind).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
registration: expect.objectContaining({ webContentsId: 41 })
|
||||
})
|
||||
)
|
||||
// Rebinding before releasing the staged guest keeps the page's live downloads.
|
||||
const binding = h.dependencies.guestBinding
|
||||
const stagedRelease = binding.release.mock.calls.findIndex(
|
||||
([registration]) => registration.webContentsId === 42
|
||||
)
|
||||
expect(binding.bind.mock.invocationCallOrder.at(-1)).toBeLessThan(
|
||||
binding.release.mock.invocationCallOrder[stagedRelease]!
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a target partition collision without retiring the old guest', async () => {
|
||||
const h = await fixture()
|
||||
h.dependencies.routeSessions.preparePage.mockResolvedValueOnce(h.routeSession)
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'browser_client_page_profile_partition_conflict'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(h.bindReplacement).not.toHaveBeenCalled()
|
||||
expect(h.dependencies.routeWebContents.beginGuestRetirement).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registration: expect.objectContaining({ webContentsId: 42 })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the new availability watcher after retiring the old generation', async () => {
|
||||
const availability = new BrowserRoutePageAvailability()
|
||||
const h = await fixture(availability)
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'completed'
|
||||
})
|
||||
availability.report(h.dependencies.routeWebContents.claimGuestLifecycle.mock.calls[0]![0])
|
||||
expect(h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{ state: 'active', pageHostGeneration: 8 }
|
||||
])
|
||||
availability.report(h.dependencies.routeWebContents.claimGuestLifecycle.mock.calls.at(-1)![0])
|
||||
expect(h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{ state: 'outcomeUnknown', pageHostGeneration: 8 }
|
||||
])
|
||||
})
|
||||
|
||||
it('retains unresolved staged cleanup evidence without replacing the old page', async () => {
|
||||
const h = await fixture()
|
||||
h.bindReplacement.mockImplementation(() => {
|
||||
throw new Error('bind failed')
|
||||
})
|
||||
h.dependencies.routeWebContents.beginGuestRetirement.mockImplementationOnce(() => {
|
||||
throw new Error('cleanup refused')
|
||||
})
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
await expect(h.executor.close()).rejects.toThrow('Browser client page executor cleanup failed')
|
||||
})
|
||||
|
||||
it('fences the staged guest when the connection is fenced during automation retirement', async () => {
|
||||
const h = await fixture()
|
||||
await h.executor.handle(
|
||||
createCommand('navigate', {
|
||||
command: { type: 'navigate', url: 'https://example.com/authenticated' }
|
||||
}),
|
||||
new AbortController().signal
|
||||
)
|
||||
h.dependencies.routeWebContents.navigateGuest.mockClear()
|
||||
h.dependencies.retireAutomation.mockImplementationOnce(async () => {
|
||||
h.executor.fenceNavigation()
|
||||
})
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'browser_client_page_executor_closed'
|
||||
})
|
||||
expect(h.dependencies.routeWebContents.revokeNavigation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registration: expect.objectContaining({ webContentsId: 42 })
|
||||
})
|
||||
)
|
||||
expect(h.dependencies.routeWebContents.navigateGuest).not.toHaveBeenCalled()
|
||||
expect(h.bindReplacement).not.toHaveBeenCalled()
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps cleanup evidence when staging fails before handing over its guest', async () => {
|
||||
const h = await fixture()
|
||||
h.dependencies.routeWebContents.registerGuest.mockReturnValueOnce(false)
|
||||
h.dependencies.routeWebContents.beginGuestRetirement.mockImplementationOnce(() => {
|
||||
throw new Error('cleanup refused')
|
||||
})
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'browser_client_page_cleanup_failed'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
await expect(h.executor.close()).rejects.toThrow('Browser client page executor cleanup failed')
|
||||
})
|
||||
|
||||
it('does not commit a replacement guest that died before the switch finished', async () => {
|
||||
const h = await fixture()
|
||||
let replacementAlive = true
|
||||
h.dependencies.routeWebContents.claimGuestLifecycle.mockImplementationOnce((registration) =>
|
||||
createLifecycleClaim(registration, Promise.resolve(), () => replacementAlive)
|
||||
)
|
||||
h.bindReplacement.mockImplementation(() => {
|
||||
replacementAlive = false
|
||||
})
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed',
|
||||
errorCode: 'browser_client_page_generation_stale'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(h.dependencies.routeWebContents.beginGuestRetirement).toHaveBeenCalledTimes(1)
|
||||
expect(h.dependencies.routeWebContents.beginGuestRetirement).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ registration: expect.objectContaining({ webContentsId: 42 }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('reports an old guest lost during a failed switch so recovery can run', async () => {
|
||||
let oldAlive = true
|
||||
const availability = new BrowserRoutePageAvailability()
|
||||
const h = await fixture(availability, () => oldAlive)
|
||||
const onPageUnavailable = vi.fn()
|
||||
Object.assign(h.dependencies, { onPageUnavailable })
|
||||
h.dependencies.routeSessions.preparePage.mockImplementationOnce(async () => {
|
||||
oldAlive = false
|
||||
availability.report(h.dependencies.routeWebContents.claimGuestLifecycle.mock.calls[0]![0])
|
||||
throw new Error('target profile rejected')
|
||||
})
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toMatchObject({
|
||||
status: 'failed'
|
||||
})
|
||||
expect(onPageUnavailable).toHaveBeenCalledWith('page-a', 7)
|
||||
expect(h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{ pageHostGeneration: 7, state: 'outcomeUnknown' }
|
||||
])
|
||||
expect(h.dependencies.guestBinding.bind).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lets the staged guest own downloads it starts while loading the page', async () => {
|
||||
const h = await fixture()
|
||||
await h.executor.handle(
|
||||
createCommand('navigate', {
|
||||
command: { type: 'navigate', url: 'https://example.com/report.csv' }
|
||||
}),
|
||||
new AbortController().signal
|
||||
)
|
||||
let owner: { pageHostGeneration: number } | undefined
|
||||
h.dependencies.routeWebContents.navigateGuest.mockImplementationOnce(async () => {
|
||||
owner = h.executor.findPageByWebContentsId(42)
|
||||
return true
|
||||
})
|
||||
expect(await h.executor.handle(switchCommand(), new AbortController().signal)).toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
expect(owner).toMatchObject({ browserPageId: 'page-a', pageHostGeneration: 8 })
|
||||
})
|
||||
|
||||
it('rejects stale generations without preparing or binding anything', async () => {
|
||||
const h = await fixture()
|
||||
const calls = h.dependencies.routeSessions.preparePage.mock.calls.length
|
||||
expect(
|
||||
await h.executor.handle(
|
||||
{ ...switchCommand(), pageHostGeneration: 6 },
|
||||
new AbortController().signal
|
||||
)
|
||||
).toMatchObject({ status: 'failed' })
|
||||
expect(h.dependencies.routeSessions.preparePage).toHaveBeenCalledTimes(calls)
|
||||
expect(h.bindReplacement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rolls back an abort after successful target binding', async () => {
|
||||
const h = await fixture()
|
||||
const controller = new AbortController()
|
||||
h.bindReplacement.mockImplementation(() => controller.abort())
|
||||
expect(await h.executor.handle(switchCommand(), controller.signal)).toMatchObject({
|
||||
status: 'failed'
|
||||
})
|
||||
expect(h.executor.hasPage('page-a', 7)).toBe(true)
|
||||
expect(h.dependencies.guestBinding.bind).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
registration: expect.objectContaining({ webContentsId: 41 })
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { BrowserClientHostCommandEvent } from '../../shared/browser-client-host-protocol'
|
||||
import { BrowserClientProfileMetadata } from '../../shared/browser-client-profile-switch-protocol'
|
||||
import { sameBrowserClientPageAuthority } from './browser-client-host-command-authority'
|
||||
import {
|
||||
assertBrowserClientPageCommandNotAborted,
|
||||
assertCurrentBrowserClientPageRenderer
|
||||
} from './browser-client-page-command-admission'
|
||||
import {
|
||||
BrowserClientPageCommandError,
|
||||
isBrowserClientPageCleanupFailure
|
||||
} from './browser-client-page-command-failure'
|
||||
import type { BrowserClientPageCommandExecutorDependencies } from './browser-client-page-command-executor-dependencies'
|
||||
import { createReservedBrowserClientPage } from './browser-client-page-creation'
|
||||
import type { BrowserClientRetainedPage } from './browser-client-page-retained-state'
|
||||
import { updateBrowserClientPageInventoryCurrentUrl } from './browser-client-page-inventory'
|
||||
import { markBrowserClientPageUnavailable } from './browser-client-page-availability'
|
||||
|
||||
export async function switchBrowserClientPageProfile(input: {
|
||||
event: BrowserClientHostCommandEvent
|
||||
signal: AbortSignal
|
||||
switches: BrowserClientProfileSwitches
|
||||
pages: Map<string, BrowserClientRetainedPage>
|
||||
dependencies: BrowserClientPageCommandExecutorDependencies
|
||||
assertAvailable(): void
|
||||
commit(page: BrowserClientRetainedPage): void
|
||||
cleanup(page: BrowserClientRetainedPage, releaseUploads: boolean): Promise<void>
|
||||
}): Promise<void> {
|
||||
const { event, signal, dependencies, switches } = input
|
||||
if (event.command.type !== 'switchProfile') {
|
||||
throw new BrowserClientPageCommandError('browser_client_page_command_invalid')
|
||||
}
|
||||
const old = input.pages.get(event.browserPageId)
|
||||
if (
|
||||
!old ||
|
||||
old.retiring ||
|
||||
old.reconciling ||
|
||||
!sameBrowserClientPageAuthority(old.inventory, event) ||
|
||||
event.command.nextPageHostGeneration <= old.generation
|
||||
) {
|
||||
throw new BrowserClientPageCommandError('browser_client_page_generation_stale')
|
||||
}
|
||||
const profile = BrowserClientProfileMetadata.parse(event.command.profile)
|
||||
const bindReplacement = dependencies.guestBinding.bindReplacement
|
||||
if (!bindReplacement) {
|
||||
throw new BrowserClientPageCommandError('browser_client_page_profile_switch_unsupported')
|
||||
}
|
||||
if (profile.id === old.inventory.browserProfileId) {
|
||||
throw new BrowserClientPageCommandError('browser_client_page_profile_switch_redundant')
|
||||
}
|
||||
const create: BrowserClientHostCommandEvent = {
|
||||
...event,
|
||||
pageHostGeneration: event.command.nextPageHostGeneration,
|
||||
command: {
|
||||
type: 'createPage',
|
||||
browserProfileId: profile.id,
|
||||
profile,
|
||||
executionHostKey: old.inventory.executionHostKey,
|
||||
...(old.inventory.workspaceId ? { workspaceId: old.inventory.workspaceId } : {})
|
||||
}
|
||||
}
|
||||
let staged: BrowserClientRetainedPage | undefined
|
||||
let bindingAttempted = false
|
||||
old.reconciling = true
|
||||
switches.stagedPages.set(event.browserPageId, null)
|
||||
try {
|
||||
await createReservedBrowserClientPage(
|
||||
{
|
||||
...dependencies,
|
||||
// A profile switch cannot change the execution route or supersede the old guest.
|
||||
retireSupersededExecutionHostPages: async () => {},
|
||||
guestBinding: { bind: () => {}, release: () => {} }
|
||||
},
|
||||
create,
|
||||
signal,
|
||||
input.assertAvailable,
|
||||
(page) => {
|
||||
staged = page
|
||||
switches.stagedPages.set(event.browserPageId, page)
|
||||
}
|
||||
)
|
||||
if (!staged || staged.registration.partition === old.registration.partition) {
|
||||
throw new BrowserClientPageCommandError('browser_client_page_profile_partition_conflict')
|
||||
}
|
||||
assertBrowserClientPageCommandNotAborted(signal)
|
||||
input.assertAvailable()
|
||||
assertCurrentBrowserClientPageRenderer(old.renderer)
|
||||
await dependencies.retireAutomation({
|
||||
keepGuestBinding: true,
|
||||
browserPageId: event.browserPageId,
|
||||
pageHostGeneration: old.generation,
|
||||
registration: old.registration
|
||||
})
|
||||
// A fence that landed during that await must stop the staged guest before it navigates.
|
||||
assertBrowserClientPageCommandNotAborted(signal)
|
||||
input.assertAvailable()
|
||||
assertCurrentBrowserClientPageRenderer(staged.renderer)
|
||||
bindingAttempted = true
|
||||
bindReplacement({
|
||||
registration: staged.registration,
|
||||
browserProfileId: profile.id
|
||||
})
|
||||
const url = old.inventory.currentUrl
|
||||
if (url && url !== 'about:blank') {
|
||||
if (!(await dependencies.routeWebContents.navigateGuest(staged.lifecycleClaim, url))) {
|
||||
throw new BrowserClientPageCommandError('browser_client_page_navigation_failed')
|
||||
}
|
||||
staged.inventory = updateBrowserClientPageInventoryCurrentUrl(staged.inventory, url)
|
||||
}
|
||||
assertBrowserClientPageCommandNotAborted(signal)
|
||||
input.assertAvailable()
|
||||
assertCurrentBrowserClientPageRenderer(staged.renderer)
|
||||
if (
|
||||
input.pages.get(event.browserPageId) !== old ||
|
||||
!old.lifecycleClaim.isCurrent() ||
|
||||
!staged.lifecycleClaim.isCurrent()
|
||||
) {
|
||||
throw new BrowserClientPageCommandError('browser_client_page_generation_stale')
|
||||
}
|
||||
input.commit(staged)
|
||||
} catch (error) {
|
||||
const oldAlive = old.lifecycleClaim.isCurrent()
|
||||
try {
|
||||
if (bindingAttempted && oldAlive) {
|
||||
// Rebind first: releasing the staged binding would otherwise cancel the page's downloads.
|
||||
dependencies.guestBinding.bind({
|
||||
registration: old.registration,
|
||||
browserProfileId: old.inventory.browserProfileId
|
||||
})
|
||||
}
|
||||
if (staged) {
|
||||
const cleanup = input.cleanup(staged, false)
|
||||
switches.retainedCleanups.add(cleanup)
|
||||
await cleanup
|
||||
switches.retainedCleanups.delete(cleanup)
|
||||
} else if (isBrowserClientPageCleanupFailure(error)) {
|
||||
// Staging failed before handing over its guest; keep the unreleased resources visible to close.
|
||||
const unresolved = Promise.reject(error)
|
||||
unresolved.catch(() => {})
|
||||
switches.retainedCleanups.add(unresolved)
|
||||
}
|
||||
} finally {
|
||||
switches.stagedPages.delete(event.browserPageId)
|
||||
old.reconciling = false
|
||||
if (!old.lifecycleClaim.isCurrent()) {
|
||||
// Its loss was reported once while reconciling suppressed it, so report it now.
|
||||
markBrowserClientPageUnavailable(input.pages, old.registration, (id, generation) =>
|
||||
dependencies.onPageUnavailable?.(id, generation)
|
||||
)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
switches.stagedPages.delete(event.browserPageId)
|
||||
old.reconciling = false
|
||||
await switches.retire(old, (page) => input.cleanup(page, true))
|
||||
}
|
||||
|
||||
/** Profile-switch state the executor's fencing and close must also see. */
|
||||
export class BrowserClientProfileSwitches {
|
||||
readonly retainedCleanups = new Set<Promise<void>>()
|
||||
/** In-flight switches by page; the staged guest once mounted. */
|
||||
readonly stagedPages = new Map<string, BrowserClientRetainedPage | null>()
|
||||
|
||||
// Why: a staged guest holds a navigation grant and owns downloads before it replaces the page.
|
||||
withStagedPages(pages: Iterable<BrowserClientRetainedPage>): BrowserClientRetainedPage[] {
|
||||
return [...pages, ...[...this.stagedPages.values()].filter((page) => page !== null)]
|
||||
}
|
||||
|
||||
async retire(
|
||||
page: BrowserClientRetainedPage,
|
||||
cleanupPage: (page: BrowserClientRetainedPage) => Promise<void>
|
||||
): Promise<void> {
|
||||
const cleanup = cleanupPage(page)
|
||||
this.retainedCleanups.add(cleanup)
|
||||
try {
|
||||
await cleanup
|
||||
this.retainedCleanups.delete(cleanup)
|
||||
} catch (error) {
|
||||
console.warn('[browser-client-page] Replaced guest cleanup remains unresolved', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,10 @@ function command(
|
||||
browserProfileId: 'profile-a',
|
||||
executionHostKey: 'execution-host-a'
|
||||
},
|
||||
closePage: { type: 'closePage' as const, targetAuthority: previousAuthority },
|
||||
closePage: {
|
||||
type: 'closePage' as const,
|
||||
targetAuthority: previousAuthority
|
||||
},
|
||||
restorePage: {
|
||||
type: 'restorePage' as const,
|
||||
browserProfileId: 'profile-a',
|
||||
@@ -244,6 +247,32 @@ describe('browser client page reconciliation adapters', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('restores a host-owned profile with the metadata the host sent', async () => {
|
||||
const { executor, dependencies } = createHarness()
|
||||
const profile = {
|
||||
id: 'remote-profile',
|
||||
label: 'Remote profile',
|
||||
scope: 'isolated' as const
|
||||
}
|
||||
const restore = command('restorePage', {
|
||||
command: {
|
||||
type: 'restorePage',
|
||||
browserProfileId: profile.id,
|
||||
profile,
|
||||
executionHostKey: 'execution-host-a'
|
||||
}
|
||||
})
|
||||
await expect(executor.handle(restore, new AbortController().signal)).resolves.toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
expect(dependencies.routeSessions.preparePage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
identity: expect.objectContaining({ browserProfileId: profile.id }),
|
||||
profileMetadata: profile
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('destroys a rekeyed guest instead of restoring authority after renderer failure', async () => {
|
||||
const { executor, renderer, routeWebContents } = createHarness()
|
||||
await executor.handle(command('createPage'), new AbortController().signal)
|
||||
|
||||
@@ -45,6 +45,7 @@ export function executeBrowserClientPageReconciliationCommand(
|
||||
return closeReconciledPage(context, event, signal)
|
||||
case 'restorePage':
|
||||
return restorePage(context, event, signal)
|
||||
case 'switchProfile':
|
||||
case 'createPage':
|
||||
case 'navigate':
|
||||
case 'automation':
|
||||
@@ -84,7 +85,10 @@ async function reclaimPage(
|
||||
page.registration,
|
||||
page.registration.partition
|
||||
)
|
||||
const nextRegistration = { ...page.registration, pageHostGeneration: event.pageHostGeneration }
|
||||
const nextRegistration = {
|
||||
...page.registration,
|
||||
pageHostGeneration: event.pageHostGeneration
|
||||
}
|
||||
const nextRendererPage = browserClientPageIdentity(nextRegistration, nextRegistration.partition)
|
||||
const rekeyGuestLifecycle = context.routeWebContents.rekeyGuestLifecycle
|
||||
const grantReconciledNavigation = context.routeWebContents.grantReconciledNavigation
|
||||
@@ -226,6 +230,9 @@ function reconciliationCreateEvent(
|
||||
command: {
|
||||
type: 'createPage',
|
||||
browserProfileId: event.command.browserProfileId,
|
||||
...(event.command.type === 'restorePage' && event.command.profile
|
||||
? { profile: event.command.profile }
|
||||
: {}),
|
||||
executionHostKey: event.command.executionHostKey,
|
||||
...(event.command.workspaceId ? { workspaceId: event.command.workspaceId } : {})
|
||||
}
|
||||
|
||||
@@ -58,3 +58,26 @@ export function forgetBrowserClientRetainedPage(
|
||||
pages.delete(browserPageId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function retireBrowserClientRetainedPage(
|
||||
pages: Map<string, BrowserClientRetainedPage>,
|
||||
failedPages: Map<string, BrowserClientHostedPageInventory>,
|
||||
browserPageId: string,
|
||||
pageHostGeneration: number,
|
||||
cleanupPage: (page: BrowserClientRetainedPage) => Promise<void>
|
||||
): Promise<boolean> {
|
||||
const page = pages.get(browserPageId)
|
||||
if (!page || page.generation !== pageHostGeneration) {
|
||||
return false
|
||||
}
|
||||
page.retiring ??= cleanupPage(page)
|
||||
try {
|
||||
await page.retiring
|
||||
} catch (error) {
|
||||
forgetBrowserClientRetainedPage(pages, browserPageId, page)
|
||||
failedPages.set(browserPageId, Object.freeze({ ...page.inventory, state: 'outcomeUnknown' }))
|
||||
throw error
|
||||
}
|
||||
forgetBrowserClientRetainedPage(pages, browserPageId, page)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
BrowserHostLeaseAuthority,
|
||||
BrowserNetworkExecutionHost
|
||||
} from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserHostLeaseAuthority } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
|
||||
const {
|
||||
bindingStore,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
|
||||
const STORAGE_IDENTITY_VERSION = 1
|
||||
const STORAGE_IDENTITY_TAG = 'orca-browser-execution-host-storage'
|
||||
|
||||
@@ -5,15 +5,18 @@ import type { BrowserGuestRegistration } from './browser-manager-types'
|
||||
import { BrowserManagerGuestPolicy } from './browser-manager-guest-policy'
|
||||
|
||||
export abstract class BrowserManagerRegistration extends BrowserManagerGuestPolicy {
|
||||
registerGuest({
|
||||
browserPageId,
|
||||
browserTabId: legacyBrowserTabId,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
sessionProfileId,
|
||||
webContentsId,
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration): boolean {
|
||||
registerGuest(
|
||||
{
|
||||
browserPageId,
|
||||
browserTabId: legacyBrowserTabId,
|
||||
workspaceId,
|
||||
worktreeId,
|
||||
sessionProfileId,
|
||||
webContentsId,
|
||||
rendererWebContentsId
|
||||
}: BrowserGuestRegistration,
|
||||
options?: { retainPreviousGuestPolicies?: boolean }
|
||||
): boolean {
|
||||
const browserTabId = browserPageId ?? legacyBrowserTabId
|
||||
// Why refuse rather than overwrite: the two halves of the registry must stay disjoint, or one
|
||||
// id resolves in both and the tool door silently prefers the document guest over the page.
|
||||
@@ -45,7 +48,12 @@ export abstract class BrowserManagerRegistration extends BrowserManagerGuestPoli
|
||||
|
||||
const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId)
|
||||
if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) {
|
||||
this.retireStaleGuestWebContents(previousWebContentsId)
|
||||
if (options?.retainPreviousGuestPolicies) {
|
||||
// A staged swap must be able to restore this still-live guest on failure.
|
||||
this.tabIdByWebContentsId.delete(previousWebContentsId)
|
||||
} else {
|
||||
this.retireStaleGuestWebContents(previousWebContentsId)
|
||||
}
|
||||
this.viewportPresetByTabId.delete(browserTabId)
|
||||
this.viewportScrollStateByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { connect } from 'node:net'
|
||||
import { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
|
||||
import { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import type { BrowserNetworkTunnelOpen } from '../../shared/browser-network-tunnel-protocol'
|
||||
import type { BrowserNetworkTunnelSocket } from './browser-network-tunnel-stream-state'
|
||||
import { readNetworkProxySettings } from './browser-network-proxy-settings-source'
|
||||
|
||||
@@ -2,10 +2,8 @@ import { mkdtempSync, realpathSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
BrowserHostLeaseAuthority,
|
||||
BrowserNetworkExecutionHost
|
||||
} from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserHostLeaseAuthority } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import { BrowserClientNetworkRouteRegistry } from './browser-client-network-route-registry'
|
||||
import { browserNetworkExecutionHostKey } from './browser-network-execution-route'
|
||||
import { deriveBrowserRoutePartition } from './browser-route-identity'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BrowserClientProfileMetadata } from '../../shared/browser-client-profile-switch-protocol'
|
||||
const PROXY_PROBE_URL = 'http://browser-route-probe.invalid/'
|
||||
|
||||
export type BrowserRouteElectronSession = {
|
||||
@@ -15,6 +16,7 @@ export type BrowserRouteProxyEndpoint = Readonly<{ host: '127.0.0.1'; port: numb
|
||||
type BrowserRouteSessionPolicyDependencies = {
|
||||
getSession(partition: string): BrowserRouteElectronSession
|
||||
setupPolicies(input: {
|
||||
profileMetadata?: BrowserClientProfileMetadata
|
||||
partition: string
|
||||
browserProfileId: string
|
||||
session: BrowserRouteElectronSession
|
||||
@@ -23,6 +25,7 @@ type BrowserRouteSessionPolicyDependencies = {
|
||||
}
|
||||
|
||||
export async function prepareBrowserRouteSessionPolicy(input: {
|
||||
profileMetadata?: BrowserClientProfileMetadata
|
||||
partition: string
|
||||
browserProfileId: string
|
||||
proxyEndpoint: BrowserRouteProxyEndpoint
|
||||
@@ -37,6 +40,7 @@ export async function prepareBrowserRouteSessionPolicy(input: {
|
||||
proxyBypassRules: '<-loopback>'
|
||||
})
|
||||
await input.dependencies.setupPolicies({
|
||||
...(input.profileMetadata ? { profileMetadata: input.profileMetadata } : {}),
|
||||
partition: input.partition,
|
||||
browserProfileId: input.browserProfileId,
|
||||
session
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BrowserClientProfileMetadata } from '../../shared/browser-client-profile-switch-protocol'
|
||||
import type {
|
||||
BrowserRoutePartitionIdentity,
|
||||
DerivedBrowserRoutePartition
|
||||
@@ -16,9 +17,10 @@ export type BrowserRoutePartitionBindingStore = {
|
||||
|
||||
export type BrowserRouteSessionRegistryDependencies = {
|
||||
derivePartition?: (identity: BrowserRoutePartitionIdentity) => DerivedBrowserRoutePartition
|
||||
validateProfile(browserProfileId: string): void
|
||||
validateProfile(browserProfileId: string, metadata?: BrowserClientProfileMetadata): void
|
||||
getSession(partition: string): BrowserRouteElectronSession
|
||||
setupPolicies(input: {
|
||||
profileMetadata?: BrowserClientProfileMetadata
|
||||
partition: string
|
||||
browserProfileId: string
|
||||
session: BrowserRouteElectronSession
|
||||
|
||||
@@ -138,6 +138,45 @@ function deferred(): {
|
||||
}
|
||||
|
||||
describe('BrowserRouteSessionRegistry', () => {
|
||||
it('prepares authority-scoped remote metadata without needing a desktop profile record', async () => {
|
||||
const { dependencies, registry } = createHarness()
|
||||
const profileMetadata = {
|
||||
id: identity.browserProfileId,
|
||||
label: 'Remote profile',
|
||||
scope: 'isolated'
|
||||
}
|
||||
await prepare(registry, { profileMetadata })
|
||||
expect(dependencies.validateProfile).toHaveBeenCalledWith(
|
||||
identity.browserProfileId,
|
||||
profileMetadata
|
||||
)
|
||||
expect(dependencies.setupPolicies).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profileMetadata,
|
||||
browserProfileId: identity.browserProfileId,
|
||||
partition: 'persist:route-a'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ id: 'other-profile', label: 'Remote', scope: 'isolated' },
|
||||
{
|
||||
id: identity.browserProfileId,
|
||||
label: 'Remote',
|
||||
scope: 'isolated',
|
||||
partition: 'persist:local-default'
|
||||
}
|
||||
])(
|
||||
'rejects inconsistent or arbitrary partition metadata before touching storage',
|
||||
async (profileMetadata) => {
|
||||
const { registry, dependencies } = createHarness()
|
||||
await expect(prepare(registry, { profileMetadata })).rejects.toThrow()
|
||||
expect(dependencies.getSession).not.toHaveBeenCalled()
|
||||
expect(dependencies.bindingStore.set).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('rekeys one prepared authority and makes the old release handle inert', async () => {
|
||||
const { dependencies, registry } = createHarness()
|
||||
const previousHandle = await prepare(registry)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BrowserClientProfileMetadata } from '../../shared/browser-client-profile-switch-protocol'
|
||||
import {
|
||||
deriveBrowserRoutePartition,
|
||||
type DerivedBrowserRoutePartition
|
||||
@@ -130,7 +131,13 @@ export class BrowserRouteSessionRegistry {
|
||||
input: BrowserRoutePreparePageInput,
|
||||
rendererFence: BrowserRouteRendererPrepareFence
|
||||
): Promise<BrowserRouteSessionHandle> {
|
||||
this.dependencies.validateProfile(input.identity.browserProfileId)
|
||||
const metadata = input.profileMetadata
|
||||
? BrowserClientProfileMetadata.parse(input.profileMetadata)
|
||||
: undefined
|
||||
if (metadata && metadata.id !== input.identity.browserProfileId) {
|
||||
throw new Error('browser_route_partition_profile_unavailable')
|
||||
}
|
||||
this.dependencies.validateProfile(input.identity.browserProfileId, metadata)
|
||||
const derived = resolveBrowserRouteSessionPartition(
|
||||
this.dependencies,
|
||||
input,
|
||||
@@ -161,7 +168,8 @@ export class BrowserRouteSessionRegistry {
|
||||
const promise = this.preparePartition(
|
||||
derived,
|
||||
input.identity.browserProfileId,
|
||||
input.proxyEndpoint
|
||||
input.proxyEndpoint,
|
||||
metadata
|
||||
)
|
||||
const pendingState = {
|
||||
partition: derived.partition,
|
||||
@@ -238,9 +246,11 @@ export class BrowserRouteSessionRegistry {
|
||||
private async preparePartition(
|
||||
derived: DerivedBrowserRoutePartition,
|
||||
browserProfileId: string,
|
||||
proxyEndpoint: ProxyEndpoint
|
||||
proxyEndpoint: ProxyEndpoint,
|
||||
profileMetadata?: BrowserClientProfileMetadata
|
||||
): Promise<PreparedPartition> {
|
||||
const session = await prepareBrowserRouteSessionPolicy({
|
||||
...(profileMetadata ? { profileMetadata } : {}),
|
||||
partition: derived.partition,
|
||||
browserProfileId,
|
||||
proxyEndpoint,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BrowserClientProfileMetadata } from '../../shared/browser-client-profile-switch-protocol'
|
||||
import { installBrowserRoutePartitionPolicies } from './browser-session-route-policies'
|
||||
import { session, webContents } from 'electron'
|
||||
import { setBrowserClientRouteWebContentsProbe } from './browser-client-download-routing'
|
||||
import type { BrowserRoutePartitionBindingStore } from './browser-route-partition-binding-store'
|
||||
@@ -38,12 +40,23 @@ const bindingStore = {
|
||||
}
|
||||
|
||||
export const browserRouteSessionRegistry = new BrowserRouteSessionRegistry({
|
||||
validateProfile: (browserProfileId) => {
|
||||
browserSessionRegistry.requireRouteBrowserProfile(browserProfileId)
|
||||
validateProfile: (browserProfileId, metadata) => {
|
||||
if (metadata) {
|
||||
if (BrowserClientProfileMetadata.parse(metadata).id !== browserProfileId) {
|
||||
throw new Error('browser_route_partition_profile_unavailable')
|
||||
}
|
||||
} else {
|
||||
browserSessionRegistry.requireRouteBrowserProfile(browserProfileId)
|
||||
}
|
||||
},
|
||||
getSession: (partition) => session.fromPartition(partition),
|
||||
setupPolicies: ({ partition, browserProfileId }) =>
|
||||
browserSessionRegistry.setupRoutePartitionPolicies(partition, browserProfileId),
|
||||
setupPolicies: ({ partition, browserProfileId, profileMetadata }) =>
|
||||
profileMetadata
|
||||
? installBrowserRoutePartitionPolicies(
|
||||
{ ...BrowserClientProfileMetadata.parse(profileMetadata), partition, source: null },
|
||||
partition
|
||||
)
|
||||
: browserSessionRegistry.setupRoutePartitionPolicies(partition, browserProfileId),
|
||||
clearPolicies: ({ partition }) => {
|
||||
browserSessionRegistry.clearRoutePartitionPolicies(partition)
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BrowserClientProfileMetadata } from '../../shared/browser-client-profile-switch-protocol'
|
||||
import type { BrowserRoutePartitionIdentity } from './browser-route-identity'
|
||||
import type { BrowserRoutePreparedPageLedger } from './browser-route-prepared-page-ledger'
|
||||
import type { BrowserRoutePageAuthority } from './browser-route-page-authority'
|
||||
@@ -12,6 +13,7 @@ export type BrowserRouteSessionHandle = Readonly<{
|
||||
}>
|
||||
|
||||
export type BrowserRoutePreparePageInput = Readonly<{
|
||||
profileMetadata?: BrowserClientProfileMetadata
|
||||
identity: BrowserRoutePartitionIdentity
|
||||
/** Pre-migration identity, so an existing partition keeps serving this route. */
|
||||
legacyIdentity?: BrowserRoutePartitionIdentity
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY } from '../../shared/browser-client-profile-switch-protocol'
|
||||
import { createHash } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
@@ -117,6 +118,7 @@ const browserClientHosts =
|
||||
browserHostClientId: getBrowserClientHostId(),
|
||||
hostCapabilities: [
|
||||
'webview',
|
||||
BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY,
|
||||
BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY,
|
||||
BROWSER_CLIENT_FILE_CHANNEL_HOST_CAPABILITY
|
||||
],
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
decodeBrowserNetworkTunnelFrame,
|
||||
encodeBrowserNetworkTunnelFrame
|
||||
} from '../../shared/browser-network-tunnel-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import type {
|
||||
RemoteRuntimeSubscription,
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type {
|
||||
BrowserHostLeaseAuthority,
|
||||
BrowserNetworkExecutionHost
|
||||
} from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserHostLeaseAuthority } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import type { BrowserNetworkTunnelOpen } from '../../shared/browser-network-tunnel-protocol'
|
||||
import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection'
|
||||
import type { RemoteRuntimeSubscriptionOptions } from '../../shared/remote-runtime-client'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
BrowserNetworkTunnelEvent,
|
||||
type BrowserHostLeaseAuthority,
|
||||
type BrowserNetworkExecutionHost
|
||||
type BrowserHostLeaseAuthority
|
||||
} from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import type { PairedRuntimeConnection } from '../../shared/paired-runtime-connection'
|
||||
import {
|
||||
subscribeRemoteRuntimeRequest,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { RuntimeBrowserCommands } from '../runtime/browser/browser-commands'
|
||||
import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory'
|
||||
import { setBrowserHostClientProfileResolver } from '../runtime/browser-host/browser-host-client-profile-metadata'
|
||||
import { browserSessionRegistry } from '../browser/browser-session-registry'
|
||||
|
||||
/** The desktop factory. Importing this file is what pulls in the Chromium browser cluster. */
|
||||
export function installElectronBrowserCommands(): void {
|
||||
setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host), {
|
||||
clientHosting: true
|
||||
})
|
||||
setBrowserHostClientProfileResolver((browserProfileId) => {
|
||||
const profile = browserSessionRegistry.getProfile(browserProfileId)
|
||||
return profile ? { id: profile.id, label: profile.label, scope: profile.scope } : undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,9 +36,10 @@ const stores = new WeakMap<DownloadTransferRuntime, BrowserClientDownloadTransfe
|
||||
*/
|
||||
export function releaseBrowserClientDownloadTransfersForPage(
|
||||
runtime: DownloadTransferRuntime,
|
||||
browserPageId: string
|
||||
browserPageId: string,
|
||||
pageHostGeneration?: number
|
||||
): Promise<void> {
|
||||
return stores.get(runtime)?.releasePage(browserPageId) ?? Promise.resolve()
|
||||
return stores.get(runtime)?.releasePage(browserPageId, pageHostGeneration) ?? Promise.resolve()
|
||||
}
|
||||
|
||||
export function getBrowserClientDownloadTransferStore(
|
||||
|
||||
@@ -217,6 +217,20 @@ describe('BrowserClientDownloadTransferStore', () => {
|
||||
expect(store.activeTransferCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('releases only the named generation of a page', async () => {
|
||||
const { store } = createStore()
|
||||
await store.accept({ ...base, contentBase64: 'AAA=', offset: 0, final: false })
|
||||
const replacement = { ...base, transferId: 'transfer-2', pageHostGeneration: 3 }
|
||||
await store.accept({ ...replacement, contentBase64: 'AAA=', offset: 0, final: false })
|
||||
|
||||
await store.releasePage(base.browserPageId, base.pageHostGeneration)
|
||||
|
||||
expect(store.activeTransferCount()).toBe(1)
|
||||
await expect(
|
||||
store.accept({ ...replacement, contentBase64: '', offset: 2, final: false })
|
||||
).resolves.not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a transfer resumed under a replaced page generation', async () => {
|
||||
const { store } = createStore()
|
||||
await store.accept({ ...base, contentBase64: 'AAA=', offset: 0, final: false })
|
||||
|
||||
@@ -103,9 +103,12 @@ export class BrowserClientDownloadTransferStore {
|
||||
}
|
||||
|
||||
// Why: page close and lease fencing must not leave half-written files in the remote workspace.
|
||||
async releasePage(browserPageId: string): Promise<void> {
|
||||
/** Omitting the generation releases every generation of the page. */
|
||||
async releasePage(browserPageId: string, pageHostGeneration?: number): Promise<void> {
|
||||
const owned = [...this.sessions.values()].filter(
|
||||
(session) => session.browserPageId === browserPageId
|
||||
(session) =>
|
||||
session.browserPageId === browserPageId &&
|
||||
(pageHostGeneration === undefined || session.pageHostGeneration === pageHostGeneration)
|
||||
)
|
||||
for (const session of owned) {
|
||||
session.canceled = true
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { BrowserHostLeaseState } from './browser-host-lease-records'
|
||||
import type { BrowserHostPagePlacementRegistry } from './browser-host-page-placement'
|
||||
import type { RuntimeBrowserClientPlacement } from '../../../shared/runtime-browser-placement'
|
||||
import {
|
||||
sameRuntimeBrowserPlacement,
|
||||
type RuntimeBrowserClientPlacement
|
||||
} from '../../../shared/runtime-browser-placement'
|
||||
import { requireBrowserHostLeaseState } from './browser-host-lease-state-resolution'
|
||||
import { pendingBrowserHostProfileSwitches } from './browser-host-client-profile-switch'
|
||||
|
||||
export function requireBrowserClientPageConnection(
|
||||
input: {
|
||||
@@ -9,6 +13,8 @@ export function requireBrowserClientPageConnection(
|
||||
placement: RuntimeBrowserClientPlacement
|
||||
pairedDeviceId: string
|
||||
connectionId: string
|
||||
/** File transfers only: a staged profile-switch guest downloads before its generation commits. */
|
||||
admitPendingProfileSwitch?: boolean
|
||||
},
|
||||
dependencies: {
|
||||
authorityRuntimeId: string
|
||||
@@ -17,6 +23,22 @@ export function requireBrowserClientPageConnection(
|
||||
pagePlacements: BrowserHostPagePlacementRegistry
|
||||
}
|
||||
): RuntimeBrowserClientPlacement {
|
||||
const pending = input.admitPendingProfileSwitch
|
||||
? pendingBrowserHostProfileSwitches(dependencies.pagePlacements).get(input.browserPageId)
|
||||
: undefined
|
||||
const current = dependencies.pagePlacements.getPlacement(input.browserPageId)
|
||||
if (
|
||||
pending &&
|
||||
current?.kind === 'client' &&
|
||||
sameRuntimeBrowserPlacement(pending, input.placement)
|
||||
) {
|
||||
// Why: the replacement downloads while loading, before the host commits its generation.
|
||||
requireBrowserClientPageConnection(
|
||||
{ ...input, placement: current, admitPendingProfileSwitch: false },
|
||||
dependencies
|
||||
)
|
||||
return pending
|
||||
}
|
||||
const state = requireBrowserHostLeaseState(
|
||||
dependencies.authorityEpoch,
|
||||
dependencies.leasesByClientId,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection'
|
||||
import { browserHostClientProfileFor } from './browser-host-client-profile-metadata'
|
||||
import type { BrowserClientHostCommandResult } from '../../../shared/browser-client-host-protocol'
|
||||
import {
|
||||
DEFAULT_CLIENT_PAGE_CREATION_TIMEOUT_MS,
|
||||
@@ -38,10 +40,10 @@ export async function createBrowserHostClientPage(
|
||||
}
|
||||
): Promise<RuntimeBrowserClientPlacement> {
|
||||
const timeoutMs = clientPageCreationTimeout(options.timeoutMs)
|
||||
const lease = dependencies.selectLease(
|
||||
options.browserHostClientId,
|
||||
options.requiredCapabilities ?? []
|
||||
)
|
||||
const lease = dependencies.selectLease(options.browserHostClientId, [
|
||||
BROWSER_HOST_WEBVIEW_CAPABILITY,
|
||||
...(options.requiredCapabilities ?? [])
|
||||
])
|
||||
if (lease.pairedDeviceId !== options.pairedDeviceId) {
|
||||
throw new Error('browser_host_lease_stale')
|
||||
}
|
||||
@@ -66,6 +68,7 @@ export async function createBrowserHostClientPage(
|
||||
const command = {
|
||||
type: 'createPage' as const,
|
||||
browserProfileId: options.browserProfileId,
|
||||
...browserHostClientProfileFor(lease, options.browserProfileId),
|
||||
executionHostKey: options.executionHostKey,
|
||||
...(options.workspaceId ? { workspaceId: options.workspaceId } : {})
|
||||
}
|
||||
@@ -110,7 +113,7 @@ export async function createBrowserHostClientPage(
|
||||
}
|
||||
}
|
||||
|
||||
function clientPageCreationTimeout(timeoutMs: number | undefined): number {
|
||||
export function clientPageCreationTimeout(timeoutMs: number | undefined): number {
|
||||
const resolved = timeoutMs ?? DEFAULT_CLIENT_PAGE_CREATION_TIMEOUT_MS
|
||||
if (
|
||||
!Number.isInteger(resolved) ||
|
||||
@@ -122,7 +125,7 @@ function clientPageCreationTimeout(timeoutMs: number | undefined): number {
|
||||
return resolved
|
||||
}
|
||||
|
||||
async function waitForClientPageCreationResult(
|
||||
export async function waitForClientPageCreationResult(
|
||||
result: Promise<BrowserClientHostCommandResult>,
|
||||
timeoutMs: number
|
||||
): Promise<BrowserClientHostCommandResult> {
|
||||
@@ -144,7 +147,7 @@ async function waitForClientPageCreationResult(
|
||||
}
|
||||
}
|
||||
|
||||
function closeUncommittedClientPage(
|
||||
export function closeUncommittedClientPage(
|
||||
state: BrowserHostLeaseState,
|
||||
browserPageId: string,
|
||||
placement: RuntimeBrowserClientPlacement
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY,
|
||||
BrowserClientProfileMetadata
|
||||
} from '../../../shared/browser-client-profile-switch-protocol'
|
||||
|
||||
type BrowserHostClientProfileResolver = (browserProfileId: string) => unknown
|
||||
|
||||
let resolveProfile: BrowserHostClientProfileResolver | null = null
|
||||
|
||||
/** Installed by the host that owns browser profiles; without one, pages carry only profile ids. */
|
||||
export function setBrowserHostClientProfileResolver(
|
||||
resolver: BrowserHostClientProfileResolver | null
|
||||
): void {
|
||||
resolveProfile = resolver
|
||||
}
|
||||
|
||||
/**
|
||||
* The host-owned profile a page command may carry to the desktop.
|
||||
*
|
||||
* Why: a desktop can only rebuild a page in a profile it was told about, so restores and
|
||||
* recoveries after a switch need the same metadata the switch sent. Its own default needs none.
|
||||
*/
|
||||
export function browserHostClientProfileFor(
|
||||
lease: { hostCapabilities: readonly string[] },
|
||||
browserProfileId: string
|
||||
): { profile?: BrowserClientProfileMetadata } {
|
||||
if (
|
||||
browserProfileId === 'default' ||
|
||||
!lease.hostCapabilities.includes(BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY)
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
const parsed = BrowserClientProfileMetadata.safeParse(resolveProfile?.(browserProfileId))
|
||||
return parsed.success && parsed.data.id === browserProfileId ? { profile: parsed.data } : {}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
import { DEFAULT_CLIENT_PAGE_CREATION_TIMEOUT_MS } from '../../../shared/browser-client-page-creation-timeouts'
|
||||
import type { BrowserClientHostCommandEvent } from '../../../shared/browser-client-host-protocol'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BrowserHostLeaseRegistry } from './browser-host-lease-registry'
|
||||
import { BrowserClientHostCommandDispatcher } from '../../browser/browser-client-host-command-dispatcher'
|
||||
import { createHarness } from '../../browser/browser-client-page-command-executor-test-harness'
|
||||
import {
|
||||
BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY,
|
||||
BrowserClientProfileMetadata
|
||||
} from '../../../shared/browser-client-profile-switch-protocol'
|
||||
import { deriveBrowserRoutePartition } from '../../browser/browser-route-identity'
|
||||
import { setBrowserHostClientProfileResolver } from './browser-host-client-profile-metadata'
|
||||
|
||||
async function fixture(supported = true, onClientPageReleased?: (browserPageId: string) => void) {
|
||||
const leases = new BrowserHostLeaseRegistry({
|
||||
authorityRuntimeId: 'runtime-a',
|
||||
authorityEpoch: 'epoch-a',
|
||||
onClientPageReleased
|
||||
})
|
||||
const lease = leases.attach({
|
||||
browserHostClientId: 'client-a',
|
||||
pairedDeviceId: 'device-a',
|
||||
connectionId: 'connection-a',
|
||||
hostCapabilities: ['webview', ...(supported ? [BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY] : [])],
|
||||
pageCommandProtocolVersion: 1,
|
||||
pageInventoryProtocolVersion: 1,
|
||||
pageReconciliationProtocolVersion: 1,
|
||||
pageInventory: []
|
||||
}).lease
|
||||
const h = createHarness()
|
||||
const bindReplacement = vi.fn()
|
||||
Object.assign(h.dependencies.guestBinding, { bindReplacement })
|
||||
h.dependencies.routeSessions.preparePage.mockImplementation(async (input) => ({
|
||||
partition: deriveBrowserRoutePartition(input.identity).partition,
|
||||
release: vi.fn()
|
||||
}))
|
||||
let guestId = 40
|
||||
h.renderer.mountPage.mockImplementation(async () => ({
|
||||
webContentsId: ++guestId
|
||||
}))
|
||||
const dispatcher = new BrowserClientHostCommandDispatcher({
|
||||
authority: { ...lease, pageCommandProtocolVersion: 1 },
|
||||
handler: (event, signal) => h.executor.handle(event, signal)
|
||||
})
|
||||
const afterSettlement = vi.fn<(event: BrowserClientHostCommandEvent) => void>()
|
||||
const delivered = vi.fn((event) => {
|
||||
void dispatcher.dispatch(event).then((result) => {
|
||||
leases.settleClientPageCommand(
|
||||
{ ...lease, connectionId: lease.connectionId },
|
||||
{ ...event, result }
|
||||
)
|
||||
afterSettlement(event)
|
||||
})
|
||||
})
|
||||
leases.attachCommandDelivery(lease, delivered)
|
||||
const placement = await leases.createClientPage({
|
||||
browserPageId: 'page-a',
|
||||
browserHostClientId: lease.browserHostClientId,
|
||||
pairedDeviceId: lease.pairedDeviceId,
|
||||
browserProfileId: 'default',
|
||||
executionHostKey: 'execution-host-a',
|
||||
workspaceId: 'workspace-a'
|
||||
})
|
||||
const authority = {
|
||||
...lease,
|
||||
browserPageId: 'page-a',
|
||||
pageHostGeneration: placement.pageHostGeneration
|
||||
}
|
||||
return {
|
||||
leases,
|
||||
h,
|
||||
dispatcher,
|
||||
delivered,
|
||||
placement,
|
||||
authority,
|
||||
bindReplacement,
|
||||
afterSettlement
|
||||
}
|
||||
}
|
||||
|
||||
const isolated = {
|
||||
id: 'remote-profile',
|
||||
label: 'Remote-only profile',
|
||||
scope: 'isolated' as const
|
||||
}
|
||||
|
||||
describe('negotiated profile switch over the command transport', () => {
|
||||
it('adopts a replacement the client finished after the caller timed out', async () => {
|
||||
const f = await fixture()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let finishMount!: (value: { webContentsId: number }) => void
|
||||
f.h.renderer.mountPage.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishMount = resolve
|
||||
})
|
||||
)
|
||||
const lateCommit = vi.fn()
|
||||
const rejected = expect(
|
||||
f.leases.switchClientPageProfile(f.authority, isolated, lateCommit)
|
||||
).rejects.toThrow('browser_host_page_creation_timeout')
|
||||
await vi.advanceTimersByTimeAsync(DEFAULT_CLIENT_PAGE_CREATION_TIMEOUT_MS + 1)
|
||||
await rejected
|
||||
expect(
|
||||
f.delivered.mock.calls.filter(([event]) => event.command.type === 'closePage')
|
||||
).toHaveLength(0)
|
||||
finishMount({ webContentsId: 99 })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
const switchEvent = f.delivered.mock.calls
|
||||
.map(([event]) => event)
|
||||
.find((event) => event.command.type === 'switchProfile')!
|
||||
if (switchEvent.command.type !== 'switchProfile') {
|
||||
throw new Error('expected a switch command')
|
||||
}
|
||||
const next = switchEvent.command.nextPageHostGeneration
|
||||
expect(
|
||||
f.delivered.mock.calls.filter(([event]) => event.command.type === 'closePage')
|
||||
).toHaveLength(0)
|
||||
expect(lateCommit).toHaveBeenCalledWith(expect.objectContaining({ pageHostGeneration: next }))
|
||||
expect(f.leases.getPlacement('page-a')).toMatchObject({ pageHostGeneration: next })
|
||||
expect(f.h.executor.snapshotPageInventory()).toMatchObject([
|
||||
{ browserProfileId: isolated.id, pageHostGeneration: next, state: 'active' }
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('switches both directions, preserves partition identity, and routes later commands to the new generation', async () => {
|
||||
const f = await fixture()
|
||||
f.afterSettlement.mockImplementation((event) => {
|
||||
if (event.command.type === 'switchProfile') {
|
||||
expect(() =>
|
||||
f.leases.issueClientPageCommand(
|
||||
{ ...f.authority, pageHostGeneration: event.pageHostGeneration },
|
||||
{ type: 'navigate', url: 'https://example.com/' }
|
||||
)
|
||||
).toThrow('profile_switch_pending')
|
||||
}
|
||||
})
|
||||
const beforePartition = f.h.executor.snapshotPageInventory()[0]!
|
||||
const next = await f.leases.switchClientPageProfile(f.authority, isolated)
|
||||
expect(next.pageHostGeneration).toBeGreaterThan(f.placement.pageHostGeneration)
|
||||
expect(f.h.executor.hasPage('page-a', next.pageHostGeneration)).toBe(true)
|
||||
const switched = f.delivered.mock.calls.at(-1)![0]
|
||||
const boundCount = f.bindReplacement.mock.calls.length
|
||||
await expect(f.dispatcher.dispatch(switched)).resolves.toEqual({
|
||||
status: 'completed'
|
||||
})
|
||||
expect(f.bindReplacement).toHaveBeenCalledTimes(boundCount)
|
||||
expect(
|
||||
f.leases.settleClientPageCommand(
|
||||
{ ...f.authority, connectionId: 'connection-a' },
|
||||
{ ...switched, result: { status: 'completed' } }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(() =>
|
||||
f.leases.issueClientPageCommand(f.authority, {
|
||||
type: 'navigate',
|
||||
url: 'https://example.com/'
|
||||
})
|
||||
).toThrow('placement_stale')
|
||||
|
||||
const navigate = f.leases.issueClientPageCommand(
|
||||
{ ...f.authority, pageHostGeneration: next.pageHostGeneration },
|
||||
{ type: 'navigate', url: 'https://example.com/' }
|
||||
)
|
||||
await expect(navigate.result).resolves.toEqual({ status: 'completed' })
|
||||
const back = await f.leases.switchClientPageProfile(
|
||||
{ ...f.authority, pageHostGeneration: next.pageHostGeneration },
|
||||
{ id: 'default', label: 'Default', scope: 'default' }
|
||||
)
|
||||
expect(f.h.executor.hasPage('page-a', back.pageHostGeneration)).toBe(true)
|
||||
expect(f.h.dependencies.routeSessions.preparePage.mock.calls[0]![0].identity).toEqual(
|
||||
f.h.dependencies.routeSessions.preparePage.mock.calls[2]![0].identity
|
||||
)
|
||||
expect(beforePartition.browserProfileId).toBe('default')
|
||||
expect(f.leases.getClientPageExecutionHostKey('page-a')).toBe('execution-host-a')
|
||||
await f.dispatcher.close()
|
||||
await f.h.executor.close()
|
||||
})
|
||||
|
||||
it('rejects an old desktop before issuing a command or changing placement', async () => {
|
||||
const f = await fixture(false)
|
||||
const calls = f.delivered.mock.calls.length
|
||||
await expect(f.leases.switchClientPageProfile(f.authority, isolated)).rejects.toThrow(
|
||||
'profile_switch_unsupported'
|
||||
)
|
||||
expect(f.delivered).toHaveBeenCalledTimes(calls)
|
||||
expect(f.leases.getPlacement('page-a')).toBe(f.placement)
|
||||
expect(f.h.executor.hasPage('page-a', f.placement.pageHostGeneration)).toBe(true)
|
||||
await f.dispatcher.close()
|
||||
await f.h.executor.close()
|
||||
})
|
||||
|
||||
it('rolls back failure and permits a later retry on the original generation', async () => {
|
||||
const f = await fixture()
|
||||
f.bindReplacement.mockImplementationOnce(() => {
|
||||
throw new Error('binding refused')
|
||||
})
|
||||
await expect(f.leases.switchClientPageProfile(f.authority, isolated)).rejects.toThrow()
|
||||
expect(f.leases.getPlacement('page-a')).toBe(f.placement)
|
||||
expect(f.h.executor.hasPage('page-a', f.placement.pageHostGeneration)).toBe(true)
|
||||
const next = await f.leases.switchClientPageProfile(f.authority, isolated)
|
||||
expect(f.h.executor.hasPage('page-a', next.pageHostGeneration)).toBe(true)
|
||||
await f.dispatcher.close()
|
||||
await f.h.executor.close()
|
||||
})
|
||||
|
||||
it('serializes a staged replacement and rejects commands addressed to the old generation while it is pending', async () => {
|
||||
const f = await fixture()
|
||||
let release = () => {}
|
||||
let started = () => {}
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
const prepare = f.h.dependencies.routeSessions.preparePage.getMockImplementation()!
|
||||
f.h.dependencies.routeSessions.preparePage.mockImplementationOnce(async (input) => {
|
||||
started()
|
||||
await gate
|
||||
return prepare(input)
|
||||
})
|
||||
const switching = f.leases.switchClientPageProfile(f.authority, isolated)
|
||||
await entered
|
||||
expect(f.leases.getPlacement('page-a')).toBe(f.placement)
|
||||
expect(f.h.executor.hasPage('page-a', f.placement.pageHostGeneration)).toBe(true)
|
||||
expect(f.bindReplacement).not.toHaveBeenCalled()
|
||||
expect(() =>
|
||||
f.leases.issueClientPageCommand(f.authority, {
|
||||
type: 'navigate',
|
||||
url: 'https://example.com/'
|
||||
})
|
||||
).toThrow('profile_switch_pending')
|
||||
await expect(f.leases.switchClientPageProfile(f.authority, isolated)).rejects.toThrow(
|
||||
'reservation_pending'
|
||||
)
|
||||
release()
|
||||
const next = await switching
|
||||
expect(f.h.executor.hasPage('page-a', next.pageHostGeneration)).toBe(true)
|
||||
await f.dispatcher.close()
|
||||
await f.h.executor.close()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[true, isolated],
|
||||
[false, undefined]
|
||||
])(
|
||||
'recreates a host-owned profile page with its metadata (desktop supports switching: %s)',
|
||||
async (supported, expected) => {
|
||||
setBrowserHostClientProfileResolver((id) => (id === isolated.id ? isolated : undefined))
|
||||
try {
|
||||
const f = await fixture(supported)
|
||||
await f.leases.createClientPage({
|
||||
browserPageId: 'page-b',
|
||||
browserHostClientId: f.authority.browserHostClientId,
|
||||
pairedDeviceId: f.authority.pairedDeviceId,
|
||||
browserProfileId: isolated.id,
|
||||
executionHostKey: 'execution-host-a'
|
||||
})
|
||||
const create = f.delivered.mock.calls.at(-1)![0]
|
||||
expect(create.command).toMatchObject({
|
||||
type: 'createPage',
|
||||
browserProfileId: isolated.id
|
||||
})
|
||||
expect(create.command.profile).toEqual(expected)
|
||||
expect(
|
||||
f.h.dependencies.routeSessions.preparePage.mock.calls.at(-1)![0].profileMetadata
|
||||
).toEqual(expected)
|
||||
await f.h.executor.close()
|
||||
} finally {
|
||||
setBrowserHostClientProfileResolver(null)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('releases download transfers of the replaced generation once the switch commits', async () => {
|
||||
const released = vi.fn()
|
||||
const f = await fixture(true, released)
|
||||
await f.leases.switchClientPageProfile(f.authority, isolated)
|
||||
expect(released).toHaveBeenCalledWith('page-a', f.placement.pageHostGeneration)
|
||||
await f.h.executor.close()
|
||||
})
|
||||
|
||||
it('admits file transfers from the staged guest only while its switch is pending', async () => {
|
||||
const f = await fixture()
|
||||
const connection = (pageHostGeneration: number, admitPendingProfileSwitch?: boolean) =>
|
||||
f.leases.requireClientPageConnection({
|
||||
browserPageId: 'page-a',
|
||||
placement: { ...f.placement, pageHostGeneration },
|
||||
pairedDeviceId: f.authority.pairedDeviceId,
|
||||
connectionId: f.authority.connectionId,
|
||||
admitPendingProfileSwitch
|
||||
})
|
||||
let stagedGeneration = 0
|
||||
f.bindReplacement.mockImplementation(({ registration }) => {
|
||||
stagedGeneration = registration.pageHostGeneration
|
||||
expect(connection(stagedGeneration, true)).toMatchObject({
|
||||
pageHostGeneration: stagedGeneration
|
||||
})
|
||||
expect(() => connection(stagedGeneration)).toThrow('browser_page_placement_stale')
|
||||
})
|
||||
await f.leases.switchClientPageProfile(f.authority, isolated)
|
||||
expect(f.bindReplacement).toHaveBeenCalledOnce()
|
||||
expect(() => connection(f.placement.pageHostGeneration, true)).toThrow(
|
||||
'browser_page_placement_stale'
|
||||
)
|
||||
expect(connection(stagedGeneration)).toMatchObject({ pageHostGeneration: stagedGeneration })
|
||||
await f.h.executor.close()
|
||||
})
|
||||
|
||||
it('rejects arbitrary partition metadata instead of admitting a local cookie jar', () => {
|
||||
expect(() =>
|
||||
BrowserClientProfileMetadata.parse({
|
||||
...isolated,
|
||||
partition: 'persist:local-default'
|
||||
})
|
||||
).toThrow()
|
||||
expect(() => BrowserClientProfileMetadata.parse({ ...isolated, scope: 'unknown' })).toThrow()
|
||||
expect(() => BrowserClientProfileMetadata.parse({ ...isolated, id: '' })).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY,
|
||||
BrowserClientProfileMetadata
|
||||
} from '../../../shared/browser-client-profile-switch-protocol'
|
||||
import type { BrowserClientHostCommandResult } from '../../../shared/browser-client-host-protocol'
|
||||
import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry'
|
||||
import type { RuntimeBrowserClientPlacement } from '../../../shared/runtime-browser-placement'
|
||||
import type { BrowserHostLeaseState } from './browser-host-lease-records'
|
||||
import type {
|
||||
BrowserClientPageAuthority,
|
||||
BrowserHostPagePlacementRegistry
|
||||
} from './browser-host-page-placement'
|
||||
import {
|
||||
clientPageCreationTimeout,
|
||||
waitForClientPageCreationResult,
|
||||
closeUncommittedClientPage,
|
||||
type BrowserClientPageExecutionHostGrant
|
||||
} from './browser-host-client-page-creation'
|
||||
|
||||
const pendingByPlacements = new WeakMap<
|
||||
BrowserHostPagePlacementRegistry,
|
||||
Map<string, RuntimeBrowserClientPlacement>
|
||||
>()
|
||||
|
||||
/** Replacement placements of switches still waiting on the client, by page. */
|
||||
export function pendingBrowserHostProfileSwitches(
|
||||
placements: BrowserHostPagePlacementRegistry
|
||||
): Map<string, RuntimeBrowserClientPlacement> {
|
||||
let pending = pendingByPlacements.get(placements)
|
||||
if (!pending) {
|
||||
pending = new Map()
|
||||
pendingByPlacements.set(placements, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
export async function switchBrowserHostClientProfile(
|
||||
authority: BrowserClientPageAuthority,
|
||||
metadata: BrowserClientProfileMetadata,
|
||||
input: {
|
||||
timeoutMs?: number
|
||||
registry: Pick<BrowserHostLeaseRegistry, 'requireClientPage' | 'select'>
|
||||
state: (clientId: string) => BrowserHostLeaseState
|
||||
placements: BrowserHostPagePlacementRegistry
|
||||
grants: Map<string, BrowserClientPageExecutionHostGrant>
|
||||
/** The client retired the old guest, so its transfers can never finish under the new generation. */
|
||||
onCommitted?: (browserPageId: string, replacedPageHostGeneration: number) => void
|
||||
/** A switch the caller already saw time out, committed once the client finished it. */
|
||||
onLateCommit?: (placement: RuntimeBrowserClientPlacement) => void
|
||||
}
|
||||
) {
|
||||
const timeoutMs = clientPageCreationTimeout(input.timeoutMs)
|
||||
const profile = BrowserClientProfileMetadata.parse(metadata)
|
||||
const expected = input.registry.requireClientPage(authority)
|
||||
const lease = input.registry.select(expected.browserHostClientId, [])
|
||||
if (
|
||||
!lease.hostCapabilities.includes(BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY) ||
|
||||
lease.browserHostGeneration !== expected.browserHostGeneration ||
|
||||
lease.pageReconciliationProtocolVersion !== 1
|
||||
) {
|
||||
throw new Error('browser_host_profile_switch_unsupported')
|
||||
}
|
||||
const state = input.state(expected.browserHostClientId)
|
||||
const ledger = state.commandLedger
|
||||
const grant = input.grants.get(authority.browserPageId)
|
||||
if (!ledger || !grant || grant.placement !== expected) {
|
||||
throw new Error('browser_page_placement_stale')
|
||||
}
|
||||
const reservation = input.placements.reserveNewClientPage(authority.browserPageId, expected)
|
||||
let completed = false
|
||||
let resultPending: Promise<BrowserClientHostCommandResult> | undefined
|
||||
const retirePlacement = () => {
|
||||
if (input.placements.getPlacement(authority.browserPageId) === expected) {
|
||||
input.placements.completePageRetirement(
|
||||
input.placements.beginPageRetirement(authority.browserPageId, expected)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Why: a held grant blocks recreating the page once its placement is retired.
|
||||
const releaseGrant = () => {
|
||||
if (input.grants.get(authority.browserPageId) === grant) {
|
||||
input.grants.delete(authority.browserPageId)
|
||||
grant.release()
|
||||
}
|
||||
}
|
||||
const closeSettledReplacement = () => {
|
||||
try {
|
||||
ledger.advancePageGeneration(
|
||||
authority.browserPageId,
|
||||
expected.pageHostGeneration,
|
||||
reservation.placement.pageHostGeneration
|
||||
)
|
||||
closeUncommittedClientPage(state, authority.browserPageId, reservation.placement)
|
||||
} catch {
|
||||
// A fenced lease or a newer reconciliation owns cleanup now.
|
||||
}
|
||||
}
|
||||
const pending = pendingBrowserHostProfileSwitches(input.placements)
|
||||
pending.set(authority.browserPageId, reservation.placement)
|
||||
const forgetPending = () => {
|
||||
if (pending.get(authority.browserPageId) === reservation.placement) {
|
||||
pending.delete(authority.browserPageId)
|
||||
}
|
||||
}
|
||||
const commit = () => {
|
||||
input.registry.requireClientPage(authority)
|
||||
const placement = input.placements.commitClientPageReplacement(reservation, expected)
|
||||
ledger.advancePageGeneration(
|
||||
authority.browserPageId,
|
||||
expected.pageHostGeneration,
|
||||
placement.pageHostGeneration
|
||||
)
|
||||
input.grants.set(authority.browserPageId, { ...grant, placement })
|
||||
input.onCommitted?.(authority.browserPageId, expected.pageHostGeneration)
|
||||
return placement
|
||||
}
|
||||
// The client retired the old guest once its switch completed, so that placement is gone.
|
||||
const retireSwitchedPage = () => {
|
||||
input.placements.cancelClientPageReservation(reservation)
|
||||
retirePlacement()
|
||||
closeSettledReplacement()
|
||||
releaseGrant()
|
||||
}
|
||||
try {
|
||||
const issued = ledger.issue({
|
||||
browserPageId: authority.browserPageId,
|
||||
pageHostGeneration: expected.pageHostGeneration,
|
||||
resultAdmission: 'reserved-page',
|
||||
command: {
|
||||
type: 'switchProfile',
|
||||
profile,
|
||||
nextPageHostGeneration: reservation.placement.pageHostGeneration
|
||||
}
|
||||
})
|
||||
resultPending = issued.result
|
||||
const result = await waitForClientPageCreationResult(issued.result, timeoutMs)
|
||||
resultPending = undefined
|
||||
completed = result.status === 'completed'
|
||||
if (result.status === 'failed') {
|
||||
throw new Error(result.errorCode)
|
||||
}
|
||||
return commit()
|
||||
} catch (error) {
|
||||
if (resultPending) {
|
||||
// Why: the client still decides; a late success is adopted, a late failure keeps the old page.
|
||||
void resultPending
|
||||
.then(
|
||||
(result) => {
|
||||
if (result.status !== 'completed') {
|
||||
input.placements.cancelClientPageReservation(reservation)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const placement = commit()
|
||||
input.onLateCommit?.(placement)
|
||||
} catch {
|
||||
retireSwitchedPage()
|
||||
}
|
||||
},
|
||||
() => {
|
||||
input.placements.cancelClientPageReservation(reservation)
|
||||
retirePlacement()
|
||||
releaseGrant()
|
||||
}
|
||||
)
|
||||
.finally(forgetPending)
|
||||
} else if (completed) {
|
||||
retireSwitchedPage()
|
||||
} else {
|
||||
input.placements.cancelClientPageReservation(reservation)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (!resultPending) {
|
||||
forgetPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,9 +113,18 @@ export class BrowserHostCommandLedger {
|
||||
if (page.outstanding >= this.maxOutstandingCommandsPerPage) {
|
||||
throw new Error('browser_host_page_command_capacity')
|
||||
}
|
||||
if (page.profileSwitchPending) {
|
||||
throw new Error('browser_host_profile_switch_pending')
|
||||
}
|
||||
if (command.type === 'switchProfile' && page.outstanding !== 0) {
|
||||
throw new Error('browser_host_profile_switch_pending')
|
||||
}
|
||||
assertBrowserHostCommandOrder(page, command, input.resultAdmission ?? 'placed-page')
|
||||
admission.commit()
|
||||
recordBrowserHostCommandOrder(page, command)
|
||||
if (command.type === 'switchProfile') {
|
||||
page.profileSwitchPending = true
|
||||
}
|
||||
const commandSequence = page.nextIssueSequence
|
||||
const event = Object.freeze({
|
||||
type: 'command' as const,
|
||||
@@ -160,6 +169,16 @@ export class BrowserHostCommandLedger {
|
||||
assertBrowserHostCommandResultAuthority(this.authority, params)
|
||||
const page = this.pages.get(params.browserPageId)
|
||||
if (!page || page.generation !== params.pageHostGeneration) {
|
||||
const previous = page?.records.get(params.commandSequence)
|
||||
if (
|
||||
page &&
|
||||
previous?.event.command.type === 'switchProfile' &&
|
||||
previous.settled &&
|
||||
previous.event.pageHostGeneration === params.pageHostGeneration &&
|
||||
params.pageHostGeneration < page.generation
|
||||
) {
|
||||
return replaySettledBrowserHostCommand(page, params)
|
||||
}
|
||||
throw new Error('browser_host_command_result_page_stale')
|
||||
}
|
||||
if (params.commandSequence < page.nextSettlementSequence) {
|
||||
@@ -180,6 +199,9 @@ export class BrowserHostCommandLedger {
|
||||
this.outstandingCommands -= 1
|
||||
page.settledSequences.push(params.commandSequence)
|
||||
this.resultCache.remember(page, record)
|
||||
if (record.event.command.type === 'switchProfile' && result.status === 'failed') {
|
||||
page.profileSwitchPending = false
|
||||
}
|
||||
if (record.event.command.type === 'closePage' && result.status === 'failed') {
|
||||
page.terminalCommandIssued = false
|
||||
} else if (record.event.command.type === 'closePage' && !page.activeCapacityReleased) {
|
||||
@@ -189,6 +211,15 @@ export class BrowserHostCommandLedger {
|
||||
return true
|
||||
}
|
||||
|
||||
advancePageGeneration(browserPageId: string, previous: number, next: number): void {
|
||||
const page = this.pages.get(browserPageId)
|
||||
if (!page || page.generation !== previous || next <= previous || page.outstanding !== 0) {
|
||||
throw new Error('browser_host_command_page_stale')
|
||||
}
|
||||
page.generation = next
|
||||
page.profileSwitchPending = false
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.closed) {
|
||||
return
|
||||
|
||||
@@ -24,9 +24,17 @@ export function isBrowserHostUnplacedPageResult(
|
||||
const page = pages.get(params.browserPageId)
|
||||
const record = page?.records.get(params.commandSequence)
|
||||
return (
|
||||
page?.generation === params.pageHostGeneration &&
|
||||
record?.event.commandId === params.commandId &&
|
||||
record.resultAdmission !== 'placed-page'
|
||||
(page?.generation === params.pageHostGeneration &&
|
||||
record?.event.commandId === params.commandId &&
|
||||
record.resultAdmission !== 'placed-page') ||
|
||||
Boolean(
|
||||
page &&
|
||||
record?.event.command.type === 'switchProfile' &&
|
||||
record.settled &&
|
||||
record.event.pageHostGeneration === params.pageHostGeneration &&
|
||||
record.event.commandId === params.commandId &&
|
||||
params.pageHostGeneration < page.generation
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export type BrowserHostCommandPageState = {
|
||||
records: Map<number, BrowserHostCommandRecord>
|
||||
outstanding: number
|
||||
settledSequences: number[]
|
||||
profileSwitchPending?: boolean
|
||||
terminalCommandIssued: boolean
|
||||
activeCapacityReleased: boolean
|
||||
}
|
||||
@@ -92,6 +93,18 @@ export function recordBrowserHostCommandOrder(
|
||||
export function snapshotBrowserHostPageCommand(
|
||||
command: BrowserClientHostCommandEvent['command']
|
||||
): BrowserClientHostCommandEvent['command'] {
|
||||
if (command.type === 'switchProfile') {
|
||||
return Object.freeze({
|
||||
...command,
|
||||
profile: Object.freeze({ ...command.profile })
|
||||
})
|
||||
}
|
||||
if ((command.type === 'createPage' || command.type === 'restorePage') && command.profile) {
|
||||
return Object.freeze({
|
||||
...command,
|
||||
profile: Object.freeze({ ...command.profile })
|
||||
})
|
||||
}
|
||||
if (command.type === 'reclaimPage') {
|
||||
return Object.freeze({
|
||||
...command,
|
||||
|
||||
@@ -14,8 +14,12 @@ export function getBrowserHostLeaseRegistry(
|
||||
if (!registry) {
|
||||
registry = new BrowserHostLeaseRegistry({
|
||||
authorityRuntimeId: runtime.getRuntimeId(),
|
||||
onClientPageReleased: (browserPageId) => {
|
||||
void releaseBrowserClientDownloadTransfersForPage(runtime, browserPageId).catch((error) => {
|
||||
onClientPageReleased: (browserPageId, pageHostGeneration) => {
|
||||
void releaseBrowserClientDownloadTransfersForPage(
|
||||
runtime,
|
||||
browserPageId,
|
||||
pageHostGeneration
|
||||
).catch((error) => {
|
||||
console.warn('[browser-host-lease] download transfer cleanup failed:', error)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { switchBrowserHostClientProfile } from './browser-host-client-profile-switch'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type {
|
||||
BrowserClientHostCommandEvent,
|
||||
@@ -28,10 +29,7 @@ import type {
|
||||
import { requireLiveBrowserClientPage } from './browser-host-page-authority'
|
||||
import type { BrowserHostFenceReason } from './browser-host-lease-fence'
|
||||
import { dispatchBrowserHostLeaseFence } from './browser-host-lease-fence-dispatch'
|
||||
import {
|
||||
BROWSER_HOST_WEBVIEW_CAPABILITY,
|
||||
selectBrowserHostLease
|
||||
} from './browser-host-capability-selection'
|
||||
import { selectBrowserHostLease } from './browser-host-capability-selection'
|
||||
import {
|
||||
createBrowserHostClientPage,
|
||||
type BrowserClientPageExecutionHostGrant,
|
||||
@@ -77,7 +75,8 @@ export class BrowserHostLeaseRegistry {
|
||||
* even when the client transport is already gone, so runtime-side per-page state (staged
|
||||
* download transfers) is not stranded.
|
||||
*/
|
||||
onClientPageReleased?: (browserPageId: string) => void
|
||||
/** A generation limits the release to that page generation's resources. */
|
||||
onClientPageReleased?: (browserPageId: string, pageHostGeneration?: number) => void
|
||||
/**
|
||||
* Called when a lease fence takes a client page's placement away. The page itself outlives
|
||||
* the host that placed it, so this hands the runtime its record back to retain rather than
|
||||
@@ -153,6 +152,8 @@ export class BrowserHostLeaseRegistry {
|
||||
placement: RuntimeBrowserClientPlacement
|
||||
pairedDeviceId: string
|
||||
connectionId: string
|
||||
/** File transfers only: a staged profile-switch guest downloads before its generation commits. */
|
||||
admitPendingProfileSwitch?: boolean
|
||||
}): RuntimeBrowserClientPlacement {
|
||||
return requireBrowserClientPageConnection(input, {
|
||||
authorityRuntimeId: this.authorityRuntimeId,
|
||||
@@ -208,17 +209,28 @@ export class BrowserHostLeaseRegistry {
|
||||
options: BrowserHostClientPageCreateOptions
|
||||
): Promise<RuntimeBrowserClientPlacement> {
|
||||
return createBrowserHostClientPage(options, {
|
||||
selectLease: (browserHostClientId, requiredCapabilities) =>
|
||||
this.select(browserHostClientId, [
|
||||
BROWSER_HOST_WEBVIEW_CAPABILITY,
|
||||
...requiredCapabilities
|
||||
]),
|
||||
selectLease: (clientId, capabilities) => this.select(clientId, capabilities),
|
||||
requireLeaseState: (lease) => this.requireLeaseState(lease),
|
||||
pagePlacements: this.pagePlacements,
|
||||
executionHostGrants: this.clientPageExecutionHostGrants
|
||||
})
|
||||
}
|
||||
|
||||
switchClientPageProfile(
|
||||
authority: BrowserClientPageAuthority,
|
||||
profile: Parameters<typeof switchBrowserHostClientProfile>[1],
|
||||
onLateCommit?: (placement: RuntimeBrowserClientPlacement) => void
|
||||
): Promise<RuntimeBrowserClientPlacement> {
|
||||
return switchBrowserHostClientProfile(authority, profile, {
|
||||
onLateCommit,
|
||||
registry: this,
|
||||
state: (clientId) => this.requireLeaseState(this.select(clientId, [])),
|
||||
placements: this.pagePlacements,
|
||||
grants: this.clientPageExecutionHostGrants,
|
||||
onCommitted: this.options.onClientPageReleased
|
||||
})
|
||||
}
|
||||
|
||||
requireClientPage(authority: BrowserClientPageAuthority): RuntimeBrowserClientPlacement {
|
||||
return requireLiveBrowserClientPage(this.pagePlacements, this.leasesByClientId, authority)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY } from '../../../shared/browser-client-profile-switch-protocol'
|
||||
import type { BrowserClientHostCommandEvent } from '../../../shared/browser-client-host-protocol'
|
||||
import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../shared/browser-client-automation-protocol'
|
||||
import { BROWSER_CLIENT_FILE_CHANNEL_HOST_CAPABILITY } from '../../../shared/browser-client-file-channel-protocol'
|
||||
@@ -12,6 +13,12 @@ export function assertBrowserHostPageCommandAdmission(
|
||||
command: BrowserClientHostCommandEvent['command'],
|
||||
requireExecutionHost: (executionHostKey: string) => void
|
||||
): void {
|
||||
if (
|
||||
command.type === 'switchProfile' &&
|
||||
!lease.hostCapabilities.includes(BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY)
|
||||
) {
|
||||
throw new Error('browser_host_profile_switch_unsupported')
|
||||
}
|
||||
if (
|
||||
command.type !== 'createPage' &&
|
||||
command.type !== 'navigate' &&
|
||||
|
||||
@@ -138,6 +138,24 @@ export class BrowserHostPagePlacementRegistry {
|
||||
return reservation.placement
|
||||
}
|
||||
|
||||
commitClientPageReplacement(
|
||||
reservation: BrowserClientPagePlacementReservation,
|
||||
expected: RuntimeBrowserClientPlacement
|
||||
): RuntimeBrowserClientPlacement {
|
||||
const state = this.placementsByPageId.get(reservation.browserPageId)
|
||||
if (
|
||||
this.reservationsByPageId.get(reservation.browserPageId) !== reservation ||
|
||||
state?.placement !== expected ||
|
||||
state.retirement
|
||||
) {
|
||||
throw new Error('browser_page_placement_stale')
|
||||
}
|
||||
this.reservationsByPageId.delete(reservation.browserPageId)
|
||||
this.releaseReservationSlot(reservation)
|
||||
this.placementsByPageId.set(reservation.browserPageId, { placement: reservation.placement })
|
||||
return reservation.placement
|
||||
}
|
||||
|
||||
cancelClientPageReservation(reservation: BrowserClientPagePlacementReservation): boolean {
|
||||
if (this.reservationsByPageId.get(reservation.browserPageId) !== reservation) {
|
||||
return false
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
BrowserClientHostCommandResult
|
||||
} from '../../../shared/browser-client-host-protocol'
|
||||
import { assertBrowserHostPageCommandAdmission } from './browser-host-page-command-admission'
|
||||
import { browserHostClientProfileFor } from './browser-host-client-profile-metadata'
|
||||
import type { BrowserHostLeaseState } from './browser-host-lease-records'
|
||||
import type {
|
||||
BrowserClientPagePlacementReservation,
|
||||
@@ -124,6 +125,7 @@ export class BrowserHostPageReconciliationActions {
|
||||
{
|
||||
type: 'restorePage',
|
||||
browserProfileId: intent.browserProfileId,
|
||||
...browserHostClientProfileFor(this.state.lease, intent.browserProfileId),
|
||||
executionHostKey: intent.executionHostKey,
|
||||
...(url ? { url } : {}),
|
||||
...(intent.workspaceId ? { workspaceId: intent.workspaceId } : {})
|
||||
@@ -182,7 +184,9 @@ function pageAuthority(page: BrowserClientHostedPageInventory) {
|
||||
|
||||
function assertNotAborted(signal: AbortSignal): void {
|
||||
if (signal.aborted) {
|
||||
throw new Error('browser_host_page_reconciliation_aborted', { cause: signal.reason })
|
||||
throw new Error('browser_host_page_reconciliation_aborted', {
|
||||
cause: signal.reason
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +197,11 @@ async function waitForCommandResult(
|
||||
let removeAbort = (): void => {}
|
||||
const aborted = new Promise<never>((_resolve, reject) => {
|
||||
const abort = (): void =>
|
||||
reject(new Error('browser_host_page_reconciliation_aborted', { cause: signal.reason }))
|
||||
reject(
|
||||
new Error('browser_host_page_reconciliation_aborted', {
|
||||
cause: signal.reason
|
||||
})
|
||||
)
|
||||
if (signal.aborted) {
|
||||
abort()
|
||||
return
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { switchBrowserHostClientProfile } from './browser-host-client-profile-switch'
|
||||
import { BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY } from '../../../shared/browser-client-profile-switch-protocol'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
function recoveryFixture(
|
||||
result: Promise<{ status: 'completed' } | { status: 'failed'; errorCode: string }>
|
||||
) {
|
||||
const expected = {
|
||||
browserHostClientId: 'client',
|
||||
browserHostGeneration: 1,
|
||||
pageHostGeneration: 1
|
||||
}
|
||||
const replacement = { ...expected, pageHostGeneration: 2 }
|
||||
const ledger = {
|
||||
issue: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ result })
|
||||
.mockReturnValue({ result: Promise.resolve({ status: 'completed' }) }),
|
||||
retirePage: vi.fn(),
|
||||
advancePageGeneration: vi.fn()
|
||||
}
|
||||
const grant = {
|
||||
placement: expected,
|
||||
release: vi.fn(),
|
||||
executionHostKey: 'host'
|
||||
}
|
||||
const placements = {
|
||||
getPlacement: vi.fn().mockReturnValue(expected),
|
||||
beginPageRetirement: vi.fn().mockReturnValue({}),
|
||||
completePageRetirement: vi.fn(),
|
||||
reserveNewClientPage: vi.fn().mockReturnValue({ placement: replacement }),
|
||||
commitClientPageReplacement: vi.fn().mockImplementation(() => {
|
||||
throw new Error('browser_page_placement_stale')
|
||||
}),
|
||||
cancelClientPageReservation: vi.fn()
|
||||
}
|
||||
const grants = new Map([['page', grant]])
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: switchBrowserHostClientProfile calls only the ledger, placement and lease members mocked here.
|
||||
const input = {
|
||||
timeoutMs: 10,
|
||||
registry: {
|
||||
requireClientPage: vi.fn().mockReturnValue(expected),
|
||||
select: vi.fn().mockReturnValue({
|
||||
...expected,
|
||||
hostCapabilities: [BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY],
|
||||
pageReconciliationProtocolVersion: 1
|
||||
})
|
||||
},
|
||||
state: () => ({
|
||||
commandLedger: ledger,
|
||||
lease: { authorityRuntimeId: 'runtime', authorityEpoch: 'epoch' }
|
||||
}),
|
||||
placements,
|
||||
grants
|
||||
} as unknown as Parameters<typeof switchBrowserHostClientProfile>[2]
|
||||
const authority = {
|
||||
...expected,
|
||||
browserPageId: 'page',
|
||||
authorityRuntimeId: 'runtime',
|
||||
authorityEpoch: 'epoch'
|
||||
}
|
||||
return { input, authority, ledger, placements, grants, grant }
|
||||
}
|
||||
|
||||
describe('profile switch failure convergence', () => {
|
||||
it('bounds a stalled result without racing a close against the pending switch', async () => {
|
||||
vi.useFakeTimers()
|
||||
const f = recoveryFixture(new Promise(() => {}))
|
||||
const rejected = expect(
|
||||
switchBrowserHostClientProfile(
|
||||
f.authority,
|
||||
{ id: 'isolated', label: 'Isolated', scope: 'isolated' },
|
||||
f.input
|
||||
)
|
||||
).rejects.toThrow('browser_host_page_creation_timeout')
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
await rejected
|
||||
// The reservation and placement both wait for the client's verdict.
|
||||
expect(f.placements.cancelClientPageReservation).not.toHaveBeenCalled()
|
||||
expect(f.placements.completePageRetirement).not.toHaveBeenCalled()
|
||||
expect(f.ledger.retirePage).not.toHaveBeenCalled()
|
||||
expect(f.ledger.issue).toHaveBeenCalledTimes(1)
|
||||
// The client may still be switching, so the page cannot be recreated under it yet.
|
||||
expect(f.grants.has('page')).toBe(true)
|
||||
expect(f.grant.release).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('orders late-success cleanup behind the original result and preserves its sequence', async () => {
|
||||
vi.useFakeTimers()
|
||||
let complete!: (result: { status: 'completed' }) => void
|
||||
const f = recoveryFixture(
|
||||
new Promise((resolve) => {
|
||||
complete = resolve
|
||||
})
|
||||
)
|
||||
const rejected = expect(
|
||||
switchBrowserHostClientProfile(
|
||||
f.authority,
|
||||
{ id: 'isolated', label: 'Isolated', scope: 'isolated' },
|
||||
f.input
|
||||
)
|
||||
).rejects.toThrow('browser_host_page_creation_timeout')
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
await rejected
|
||||
expect(f.ledger.issue).toHaveBeenCalledTimes(1)
|
||||
expect(f.ledger.advancePageGeneration).not.toHaveBeenCalled()
|
||||
complete({ status: 'completed' })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(f.ledger.advancePageGeneration).toHaveBeenCalledWith('page', 1, 2)
|
||||
expect(f.ledger.issue).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
pageHostGeneration: 2,
|
||||
command: expect.objectContaining({ type: 'closePage' })
|
||||
})
|
||||
)
|
||||
expect(f.ledger.advancePageGeneration.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
f.ledger.issue.mock.invocationCallOrder[1]
|
||||
)
|
||||
expect(f.placements.completePageRetirement).toHaveBeenCalledOnce()
|
||||
expect(f.grants.has('page')).toBe(false)
|
||||
expect(f.grant.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the original placement when a timed-out switch later fails on the client', async () => {
|
||||
vi.useFakeTimers()
|
||||
let settle!: (result: { status: 'failed'; errorCode: string }) => void
|
||||
const f = recoveryFixture(
|
||||
new Promise((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
)
|
||||
const rejected = expect(
|
||||
switchBrowserHostClientProfile(
|
||||
f.authority,
|
||||
{ id: 'isolated', label: 'Isolated', scope: 'isolated' },
|
||||
f.input
|
||||
)
|
||||
).rejects.toThrow('browser_host_page_creation_timeout')
|
||||
await vi.advanceTimersByTimeAsync(11)
|
||||
await rejected
|
||||
settle({
|
||||
status: 'failed',
|
||||
errorCode: 'browser_client_page_navigation_failed'
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(f.ledger.issue).toHaveBeenCalledTimes(1)
|
||||
expect(f.placements.completePageRetirement).not.toHaveBeenCalled()
|
||||
expect(f.grants.has('page')).toBe(true)
|
||||
expect(f.grant.release).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the completed replacement when the host cannot commit it', async () => {
|
||||
const f = recoveryFixture(Promise.resolve({ status: 'completed' }))
|
||||
await expect(
|
||||
switchBrowserHostClientProfile(
|
||||
f.authority,
|
||||
{ id: 'isolated', label: 'Isolated', scope: 'isolated' },
|
||||
f.input
|
||||
)
|
||||
).rejects.toThrow('browser_page_placement_stale')
|
||||
expect(f.ledger.advancePageGeneration).toHaveBeenCalledWith('page', 1, 2)
|
||||
expect(f.ledger.issue).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
pageHostGeneration: 2,
|
||||
command: expect.objectContaining({ type: 'closePage' })
|
||||
})
|
||||
)
|
||||
expect(f.grants.has('page')).toBe(false)
|
||||
expect(f.grant.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentBrowserBridge } from '../../browser/agent-browser-bridge'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import type { BrowserNetworkExecutionHost } from '../../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../../shared/browser-network-execution-host'
|
||||
import type { BrowserHostLeaseRegistry } from '../browser-host/browser-host-lease-registry'
|
||||
import type { RuntimeBrowserPageRegistry } from '../runtime-browser-page-registry'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { BrowserHostLeaseRegistry } from '../browser-host/browser-host-lease-registry'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeBrowserPageRegistry } from '../runtime-browser-page-registry'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getProfile: vi.fn(),
|
||||
getDefaultProfile: vi.fn(),
|
||||
getLocalProfile: vi.fn(),
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
wait: vi.fn()
|
||||
}))
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { on: mocks.on, removeListener: mocks.removeListener }
|
||||
}))
|
||||
vi.mock('../../browser/browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
getProfile: mocks.getProfile,
|
||||
getDefaultProfile: mocks.getDefaultProfile
|
||||
}
|
||||
}))
|
||||
vi.mock('../../browser/browser-manager', () => ({
|
||||
browserManager: { getSessionProfileIdForTab: mocks.getLocalProfile }
|
||||
}))
|
||||
vi.mock('../../ipc/browser-tab-registration-wait', () => ({
|
||||
waitForTabRegistration: mocks.wait
|
||||
}))
|
||||
import { RuntimeBrowserProfiles } from './browser-profiles'
|
||||
import { RuntimeBrowserTabCatalog } from './browser-tab-catalog'
|
||||
|
||||
const profiles = [
|
||||
{ scope: 'default', id: 'default', label: 'Default', partition: 'persist:local-default' },
|
||||
{ scope: 'isolated', id: 'isolated', label: 'Isolated', partition: 'persist:local-isolated' }
|
||||
]
|
||||
|
||||
function fixture(currentProfile = 'isolated') {
|
||||
const pages = new RuntimeBrowserPageRegistry()
|
||||
pages.publishClientPage({
|
||||
browserPageId: 'remote-page',
|
||||
workspaceId: 'workspace',
|
||||
browserProfileId: currentProfile,
|
||||
executionHostKey: 'remote-host',
|
||||
placement: {
|
||||
kind: 'client',
|
||||
browserHostClientId: 'desktop',
|
||||
browserHostGeneration: 1,
|
||||
pageHostGeneration: 1
|
||||
},
|
||||
url: 'https://example.com/authenticated',
|
||||
loading: false,
|
||||
active: true
|
||||
})
|
||||
const window = vi.fn(() => {
|
||||
throw new Error('must not access renderer')
|
||||
})
|
||||
const bridge = vi.fn(() => {
|
||||
throw new Error('must not require local bridge')
|
||||
})
|
||||
const leases = new BrowserHostLeaseRegistry({ authorityRuntimeId: 'runtime' })
|
||||
const switchProfile = vi
|
||||
.spyOn(leases, 'switchClientPageProfile')
|
||||
.mockRejectedValue(new Error('requires staged replacement support on both peers'))
|
||||
const notify = vi.fn()
|
||||
const localTarget = vi.fn(async () => {
|
||||
throw new Error('must not resolve a server-local target')
|
||||
})
|
||||
const runtime = new RuntimeBrowserProfiles(
|
||||
{
|
||||
getRuntimeBrowserPageRegistry: () => pages,
|
||||
getAuthoritativeWindow: window,
|
||||
getBrowserHostLeaseRegistry: () => leases,
|
||||
notifyHeadlessBrowserSessionTabsChanged: notify
|
||||
},
|
||||
{ resolveBrowserCommandTarget: localTarget, requireAgentBrowserBridge: bridge },
|
||||
new RuntimeBrowserTabCatalog(
|
||||
{
|
||||
getAgentBrowserBridge: () => null,
|
||||
// Folder selectors resolve here, never through the server-local worktree resolver.
|
||||
resolveBrowserWorkspace: async (selector) => ({
|
||||
id: selector === 'id:folder:workspace' ? 'workspace' : selector
|
||||
}),
|
||||
getRuntimeBrowserPageRegistry: () => pages
|
||||
},
|
||||
{ requireAgentBrowserBridge: bridge }
|
||||
)
|
||||
)
|
||||
return { pages, runtime, window, bridge, switchProfile, notify, localTarget }
|
||||
}
|
||||
|
||||
describe('client placement profile switch safety', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.getProfile.mockImplementation((id) => profiles.find((profile) => profile.id === id))
|
||||
mocks.getDefaultProfile.mockReturnValue(profiles[0])
|
||||
mocks.getLocalProfile.mockReturnValue('default')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['isolated', 'default'],
|
||||
['default', 'isolated']
|
||||
])('rejects %s to %s without mutating the old guest or metadata', async (current, next) => {
|
||||
const { pages, runtime, window, bridge } = fixture(current)
|
||||
const before = pages.getPage('remote-page')
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({ page: 'remote-page', profileId: next })
|
||||
).rejects.toThrow('requires staged replacement support on both peers')
|
||||
expect(pages.getPage('remote-page')).toBe(before)
|
||||
expect(window).not.toHaveBeenCalled()
|
||||
expect(bridge).not.toHaveBeenCalled()
|
||||
expect(mocks.getLocalProfile).not.toHaveBeenCalled()
|
||||
expect(mocks.on).not.toHaveBeenCalled()
|
||||
expect(mocks.wait).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits a proven replacement profile and generation before notifying session readers', async () => {
|
||||
const { pages, runtime, switchProfile, notify } = fixture()
|
||||
const previous = pages.getPage('remote-page')!
|
||||
const replacement = { ...previous.placement, pageHostGeneration: 2 }
|
||||
switchProfile.mockResolvedValue(replacement)
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({ page: 'remote-page', profileId: 'default' })
|
||||
).resolves.toMatchObject({ profileId: 'default' })
|
||||
expect(switchProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ browserPageId: 'remote-page', pageHostGeneration: 1 }),
|
||||
{ id: 'default', label: 'Default', scope: 'default' },
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(pages.getPage('remote-page')).toMatchObject({
|
||||
browserProfileId: 'default',
|
||||
placement: replacement,
|
||||
metadataRevision: 0
|
||||
})
|
||||
expect(notify).toHaveBeenCalledWith('workspace')
|
||||
})
|
||||
|
||||
it('uses authoritative profile identity for an actual no-op', async () => {
|
||||
const { runtime, window } = fixture()
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({ page: 'remote-page', profileId: 'isolated' })
|
||||
).resolves.toEqual({
|
||||
browserPageId: 'remote-page',
|
||||
profileId: 'isolated',
|
||||
profileLabel: 'Isolated'
|
||||
})
|
||||
expect(mocks.getLocalProfile).not.toHaveBeenCalled()
|
||||
expect(window).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves the active client page without a server-local bridge', async () => {
|
||||
const { runtime, bridge } = fixture()
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({ worktree: 'workspace', profileId: 'isolated' })
|
||||
).resolves.toMatchObject({ browserPageId: 'remote-page', profileId: 'isolated' })
|
||||
expect(bridge).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an invalid profile without changing authoritative state', async () => {
|
||||
const { pages, runtime, window } = fixture()
|
||||
const before = pages.getPage('remote-page')
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({ page: 'remote-page', profileId: 'missing' })
|
||||
).rejects.toThrow('Browser profile missing was not found')
|
||||
expect(pages.getPage('remote-page')).toBe(before)
|
||||
expect(window).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not accept a page from a different explicit workspace', async () => {
|
||||
const { runtime } = fixture()
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({
|
||||
page: 'remote-page',
|
||||
worktree: 'another-workspace',
|
||||
profileId: 'isolated'
|
||||
})
|
||||
).rejects.toThrow('was not found in this worktree')
|
||||
})
|
||||
|
||||
it('resolves a folder-workspace page without the server-local target path', async () => {
|
||||
const { runtime, localTarget, window } = fixture()
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({ worktree: 'id:folder:workspace', profileId: 'isolated' })
|
||||
).resolves.toMatchObject({ browserPageId: 'remote-page', profileId: 'isolated' })
|
||||
expect(localTarget).not.toHaveBeenCalled()
|
||||
expect(window).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes synthetic default before comparing the authoritative identity', async () => {
|
||||
const canonical = { id: 'canonical-default', label: 'Default', partition: 'persist:canonical' }
|
||||
mocks.getProfile.mockReturnValue(undefined)
|
||||
mocks.getDefaultProfile.mockReturnValue(canonical)
|
||||
const { runtime } = fixture('canonical-default')
|
||||
await expect(
|
||||
runtime.browserTabSetProfile({ page: 'remote-page', profileId: 'default' })
|
||||
).resolves.toMatchObject({ profileId: 'canonical-default' })
|
||||
})
|
||||
})
|
||||
@@ -27,15 +27,25 @@ import type {
|
||||
} from './browser-command-contracts'
|
||||
import type { RuntimeBrowserCommandTargets } from './browser-command-targets'
|
||||
import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog'
|
||||
import type { RuntimeBrowserClientPlacement } from '../../../shared/runtime-browser-placement'
|
||||
|
||||
export class RuntimeBrowserProfiles {
|
||||
constructor(
|
||||
private readonly host: Pick<RuntimeBrowserCommandHost, 'getAuthoritativeWindow'>,
|
||||
private readonly host: Pick<
|
||||
RuntimeBrowserCommandHost,
|
||||
| 'getAuthoritativeWindow'
|
||||
| 'getRuntimeBrowserPageRegistry'
|
||||
| 'getBrowserHostLeaseRegistry'
|
||||
| 'notifyHeadlessBrowserSessionTabsChanged'
|
||||
>,
|
||||
private readonly targets: Pick<
|
||||
RuntimeBrowserCommandTargets,
|
||||
'requireAgentBrowserBridge' | 'resolveBrowserCommandTarget'
|
||||
>,
|
||||
private readonly catalog: Pick<RuntimeBrowserTabCatalog, 'describeBrowserTab'>
|
||||
private readonly catalog: Pick<
|
||||
RuntimeBrowserTabCatalog,
|
||||
'describeBrowserTab' | 'resolveClientHostedBrowserPage'
|
||||
>
|
||||
) {}
|
||||
|
||||
async browserTabSetProfile(
|
||||
@@ -43,13 +53,6 @@ export class RuntimeBrowserProfiles {
|
||||
profileId: string
|
||||
} & BrowserCommandTargetParams
|
||||
): Promise<BrowserTabSetProfileResult> {
|
||||
const target = await this.targets.resolveBrowserCommandTarget(params)
|
||||
const browserPageId =
|
||||
target.browserPageId ??
|
||||
this.targets.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
|
||||
if (!browserPageId) {
|
||||
throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree')
|
||||
}
|
||||
// Why: 'default' is a synthetic id; fall back to the registry's default profile when not registered.
|
||||
const profile =
|
||||
browserSessionRegistry.getProfile(params.profileId) ??
|
||||
@@ -61,6 +64,51 @@ export class RuntimeBrowserProfiles {
|
||||
)
|
||||
}
|
||||
|
||||
// Why first: the server-local target path cannot resolve folder workspaces or remote guests.
|
||||
const clientPage = await this.catalog.resolveClientHostedBrowserPage(params)
|
||||
if (clientPage) {
|
||||
const { browserPageId } = clientPage
|
||||
if (clientPage.browserProfileId === profile.id) {
|
||||
return { browserPageId, profileId: profile.id, profileLabel: profile.label }
|
||||
}
|
||||
const authority = this.host.getBrowserHostLeaseRegistry()
|
||||
const publish = (placement: RuntimeBrowserClientPlacement): void => {
|
||||
this.host
|
||||
.getRuntimeBrowserPageRegistry()
|
||||
.replaceClientPagePlacement(
|
||||
browserPageId,
|
||||
clientPage.placement,
|
||||
placement,
|
||||
undefined,
|
||||
profile.id
|
||||
)
|
||||
this.host.notifyHeadlessBrowserSessionTabsChanged?.(clientPage.workspaceId)
|
||||
}
|
||||
const placement = await authority.switchClientPageProfile(
|
||||
{
|
||||
authorityRuntimeId: authority.authorityRuntimeId,
|
||||
authorityEpoch: authority.authorityEpoch,
|
||||
browserPageId,
|
||||
browserHostClientId: clientPage.placement.browserHostClientId,
|
||||
browserHostGeneration: clientPage.placement.browserHostGeneration,
|
||||
pageHostGeneration: clientPage.placement.pageHostGeneration
|
||||
},
|
||||
{ id: profile.id, label: profile.label, scope: profile.scope },
|
||||
// Why: a switch that finished after the caller's timeout still moved the page.
|
||||
publish
|
||||
)
|
||||
publish(placement)
|
||||
return { browserPageId, profileId: profile.id, profileLabel: profile.label }
|
||||
}
|
||||
|
||||
const target = await this.targets.resolveBrowserCommandTarget(params)
|
||||
const browserPageId =
|
||||
target.browserPageId ??
|
||||
this.targets.requireAgentBrowserBridge().getActivePageId(target.worktreeId)
|
||||
if (!browserPageId) {
|
||||
throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree')
|
||||
}
|
||||
|
||||
// Why: short-circuit no-op switches so the renderer doesn't needlessly tear down and remount the webview.
|
||||
const currentProfileId = browserManager.getSessionProfileIdForTab(browserPageId) ?? 'default'
|
||||
if (currentProfileId === profile.id) {
|
||||
|
||||
@@ -45,7 +45,8 @@ function requireFileChannelPage(
|
||||
browserPageId: params.browserPageId,
|
||||
placement,
|
||||
pairedDeviceId,
|
||||
connectionId
|
||||
connectionId,
|
||||
admitPendingProfileSwitch: true
|
||||
})
|
||||
if (
|
||||
leases.requireLease({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol'
|
||||
import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route'
|
||||
import type { BrowserHostLeaseRegistry } from './browser-host/browser-host-lease-registry'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../shared/browser-network-execution-host'
|
||||
import { parseExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
|
||||
import type { SshConnectionState } from '../../shared/ssh-types'
|
||||
|
||||
@@ -159,18 +159,23 @@ export class RuntimeBrowserPageRegistry {
|
||||
expected: RuntimeBrowserClientPlacement,
|
||||
placement: RuntimeBrowserClientPlacement,
|
||||
/** The route key the replacing host was actually placed under; keys do not survive a restart. */
|
||||
executionHostKey?: string
|
||||
executionHostKey?: string,
|
||||
browserProfileId?: string
|
||||
): RuntimeBrowserClientPage {
|
||||
const current = this.requireExactPage(browserPageId, expected)
|
||||
if (executionHostKey !== undefined) {
|
||||
assertIdentity(executionHostKey)
|
||||
}
|
||||
if (browserProfileId !== undefined) {
|
||||
assertIdentity(browserProfileId)
|
||||
}
|
||||
// Why the revision restarts: it is the replacing host's own counter, and a host that just took
|
||||
// this placement over counts from zero. Keeping the old high-water mark deafens the page to
|
||||
// that host until it catches up.
|
||||
const next = freezePage({
|
||||
...current,
|
||||
...(executionHostKey === undefined ? {} : { executionHostKey }),
|
||||
...(browserProfileId === undefined ? {} : { browserProfileId }),
|
||||
metadataRevision: 0,
|
||||
placement: Object.freeze({ ...placement })
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ResolvedWorktree } from '../../runtime-worktree-path-identity'
|
||||
import type { ExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import type { RuntimeAccountController } from '../../runtime-account-controller'
|
||||
import type { BrowserNetworkExecutionHost } from '../../../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../../../shared/browser-network-execution-host'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { BrowserSessionTabSelectionOptions } from '../../browser-tab-create-publication'
|
||||
import type { RuntimeStore } from '../../runtime-store-contract'
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isFloatingWorkspaceSelector
|
||||
} from '../../../../shared/floating-workspace-worktree'
|
||||
import { resolveFloatingTerminalCwd } from '../../../ipc/floating-workspace-directory'
|
||||
import type { BrowserNetworkExecutionHost } from '../../../../shared/browser-client-host-protocol'
|
||||
import type { BrowserNetworkExecutionHost } from '../../../../shared/browser-network-execution-host'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
getConnectionExecutionHostId,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
createRetainedHostFixture,
|
||||
RETAINED_FIXTURE_PAGE
|
||||
} from './browser-client-page-retained-host-fixture'
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('retained profile replacement staging', () => {
|
||||
it('keeps the old visible guest alive while another partition mounts under the same page id', async () => {
|
||||
const fixture = createRetainedHostFixture()
|
||||
await fixture.mount()
|
||||
const old = fixture.attach()
|
||||
const oldWebview = old.webview
|
||||
const next = { ...RETAINED_FIXTURE_PAGE, partition: 'persist:route-b', pageHostGeneration: 4 }
|
||||
await fixture.mount(next)
|
||||
expect(oldWebview.isConnected).toBe(true)
|
||||
expect(fixture.registry.getMemoryProfile()).toMatchObject({ attachedPageCount: 2 })
|
||||
fixture.registry.retirePage(next)
|
||||
expect(oldWebview.isConnected).toBe(true)
|
||||
expect(() => old.nextMetadataRevision()).not.toThrow()
|
||||
old.detach()
|
||||
fixture.registry.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,9 @@
|
||||
import {
|
||||
BrowserClientProfileMetadata,
|
||||
BrowserClientProfileSwitchCommand
|
||||
} from './browser-client-profile-switch-protocol'
|
||||
import { z } from 'zod'
|
||||
import { BrowserNetworkExecutionHost } from './browser-network-execution-host'
|
||||
import {
|
||||
BrowserClientAutomationCommand,
|
||||
BrowserClientAutomationResult
|
||||
@@ -195,34 +200,6 @@ export const BrowserClientHostLeaseAuthority = BrowserHostLeaseAuthority.extend(
|
||||
|
||||
export type BrowserClientHostLeaseAuthority = z.infer<typeof BrowserClientHostLeaseAuthority>
|
||||
|
||||
const BrowserNetworkNativeExecutionHost = z.object({
|
||||
kind: z.literal('native'),
|
||||
runtimeId: Identity,
|
||||
revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
const BrowserNetworkSshExecutionHost = z.object({
|
||||
kind: z.literal('ssh'),
|
||||
targetId: Identity,
|
||||
providerEpoch: Identity,
|
||||
connectionGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
const BrowserNetworkWslExecutionHost = z.object({
|
||||
kind: z.literal('wsl'),
|
||||
runtimeId: Identity,
|
||||
revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
distro: Identity
|
||||
})
|
||||
|
||||
export const BrowserNetworkExecutionHost = z.discriminatedUnion('kind', [
|
||||
BrowserNetworkNativeExecutionHost,
|
||||
BrowserNetworkSshExecutionHost,
|
||||
BrowserNetworkWslExecutionHost
|
||||
])
|
||||
|
||||
export type BrowserNetworkExecutionHost = z.infer<typeof BrowserNetworkExecutionHost>
|
||||
|
||||
const BrowserClientPageCommandAuthority = BrowserClientHostLeaseAuthority.extend({
|
||||
pageCommandProtocolVersion: PageCommandProtocolVersion,
|
||||
browserPageId: Identity,
|
||||
@@ -234,6 +211,8 @@ const BrowserClientPageCommandAuthority = BrowserClientHostLeaseAuthority.extend
|
||||
const BrowserClientHostCreatePageCommand = z.object({
|
||||
type: z.literal('createPage'),
|
||||
browserProfileId: Identity,
|
||||
// Sent only to profile-switch-v1 desktops, for host-owned profiles the desktop does not know.
|
||||
profile: BrowserClientProfileMetadata.optional(),
|
||||
executionHostKey: Identity,
|
||||
// The client never interprets this; it only echoes it back in the page inventory so a restarted
|
||||
// runtime can rebuild the workspace association it holds nowhere else.
|
||||
@@ -261,12 +240,14 @@ const BrowserClientHostClosePageCommand = z.object({
|
||||
const BrowserClientHostRestorePageCommand = z.object({
|
||||
type: z.literal('restorePage'),
|
||||
browserProfileId: Identity,
|
||||
profile: BrowserClientProfileMetadata.optional(),
|
||||
executionHostKey: Identity,
|
||||
url: z.string().min(1).max(8192).optional(),
|
||||
workspaceId: Identity.optional()
|
||||
})
|
||||
|
||||
export const BrowserClientHostPageCommand = z.discriminatedUnion('type', [
|
||||
BrowserClientProfileSwitchCommand,
|
||||
BrowserClientHostCreatePageCommand,
|
||||
BrowserClientHostNavigateCommand,
|
||||
BrowserClientHostReclaimPageCommand,
|
||||
@@ -325,7 +306,9 @@ export const BrowserClientHostCommandResultParams = BrowserClientPageCommandAuth
|
||||
result: BrowserClientHostCommandResult
|
||||
})
|
||||
|
||||
export const BrowserClientHostCommandResultAck = z.object({ accepted: z.boolean() })
|
||||
export const BrowserClientHostCommandResultAck = z.object({
|
||||
accepted: z.boolean()
|
||||
})
|
||||
|
||||
export const BrowserClientHostEvent = z.union([
|
||||
BrowserClientHostReady,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const BROWSER_CLIENT_PROFILE_SWITCH_CAPABILITY = 'profile-switch-v1' as const
|
||||
|
||||
// Only display metadata crosses the boundary; the desktop derives its own scoped partition.
|
||||
export const BrowserClientProfileMetadata = z.strictObject({
|
||||
id: z.string().min(1).max(256),
|
||||
label: z.string().min(1).max(256),
|
||||
scope: z.enum(['default', 'isolated', 'imported'])
|
||||
})
|
||||
export type BrowserClientProfileMetadata = z.infer<typeof BrowserClientProfileMetadata>
|
||||
|
||||
export const BrowserClientProfileSwitchCommand = z.object({
|
||||
type: z.literal('switchProfile'),
|
||||
profile: BrowserClientProfileMetadata,
|
||||
nextPageHostGeneration: z.number().int().min(1).max(0xffff_ffff)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const Identity = z.string().min(1).max(256)
|
||||
|
||||
const BrowserNetworkNativeExecutionHost = z.object({
|
||||
kind: z.literal('native'),
|
||||
runtimeId: Identity,
|
||||
revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
const BrowserNetworkSshExecutionHost = z.object({
|
||||
kind: z.literal('ssh'),
|
||||
targetId: Identity,
|
||||
providerEpoch: Identity,
|
||||
connectionGeneration: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
|
||||
const BrowserNetworkWslExecutionHost = z.object({
|
||||
kind: z.literal('wsl'),
|
||||
runtimeId: Identity,
|
||||
revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
distro: Identity
|
||||
})
|
||||
|
||||
export const BrowserNetworkExecutionHost = z.discriminatedUnion('kind', [
|
||||
BrowserNetworkNativeExecutionHost,
|
||||
BrowserNetworkSshExecutionHost,
|
||||
BrowserNetworkWslExecutionHost
|
||||
])
|
||||
|
||||
export type BrowserNetworkExecutionHost = z.infer<typeof BrowserNetworkExecutionHost>
|
||||
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { RuntimeClient } from '../../src/cli/runtime/client'
|
||||
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
|
||||
import {
|
||||
launchPairedElectronClient,
|
||||
type PairedElectronClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import {
|
||||
openClientHostedFixturePage,
|
||||
selectPairedWorktreeGroup,
|
||||
startClientHostedMarkerFixture,
|
||||
waitForPairedWorktreeId,
|
||||
waitForRenderedClientWebview
|
||||
} from './helpers/client-hosted-browser-fixture'
|
||||
|
||||
test('switches a paired client profile while preserving isolated native cookie jars', async ({
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.setTimeout(240_000)
|
||||
const fixture = await startClientHostedMarkerFixture()
|
||||
const host = await launchHeadlessPairedRuntimeHost()
|
||||
const runtime = new RuntimeClient(host.userDataDir, 30_000)
|
||||
let client: PairedElectronClient | null = null
|
||||
try {
|
||||
await runtime.call('repo.add', { path: testRepoPath, kind: 'git' })
|
||||
await runtime.call('terminal.create', {
|
||||
worktree: `path:${testRepoPath}`,
|
||||
title: 'Profile cookies'
|
||||
})
|
||||
client = await launchPairedElectronClient(host.offer, testInfo, 'Profile cookies client')
|
||||
const worktreeId = await waitForPairedWorktreeId(client.page, testRepoPath)
|
||||
await selectPairedWorktreeGroup(client.page, client.environmentId, worktreeId)
|
||||
const browser = await openClientHostedFixturePage(client, worktreeId, fixture.markerUrl)
|
||||
await waitForRenderedClientWebview(
|
||||
client.page,
|
||||
{
|
||||
urlPrefix: fixture.origin,
|
||||
remotePageId: browser.remotePageId
|
||||
},
|
||||
'profile fixture did not render'
|
||||
)
|
||||
const guestState = (cookieValue?: string) =>
|
||||
client!.app.evaluate(
|
||||
async ({ webContents }, args) => {
|
||||
const guest = webContents
|
||||
.getAllWebContents()
|
||||
.find(
|
||||
(contents) =>
|
||||
contents.getType() === 'webview' && contents.getURL().startsWith(args.origin)
|
||||
)
|
||||
if (!guest) {
|
||||
return null
|
||||
}
|
||||
if (args.cookieValue) {
|
||||
await guest.session.cookies.set({
|
||||
url: args.origin,
|
||||
name: 'profile-probe',
|
||||
value: args.cookieValue
|
||||
})
|
||||
}
|
||||
return {
|
||||
id: guest.id,
|
||||
cookies: (await guest.session.cookies.get({ url: args.origin })).map(
|
||||
(cookie) => cookie.value
|
||||
)
|
||||
}
|
||||
},
|
||||
{ origin: fixture.origin, cookieValue }
|
||||
)
|
||||
const before = await guestState('default-cookie')
|
||||
expect(before?.cookies).toContain('default-cookie')
|
||||
const created = await runtime.call<{ profile: { id: string } }>('browser.profileCreate', {
|
||||
label: 'Isolated profile cookie proof',
|
||||
scope: 'isolated'
|
||||
})
|
||||
const switchTo = (profileId: string) =>
|
||||
runtime.call('browser.tabSetProfile', {
|
||||
worktree: `path:${testRepoPath}`,
|
||||
page: browser.remotePageId,
|
||||
profileId
|
||||
})
|
||||
await switchTo(created.result.profile.id)
|
||||
await expect.poll(guestState).toMatchObject({ cookies: [] })
|
||||
const isolated = await guestState('isolated-cookie')
|
||||
expect(isolated?.id).not.toBe(before?.id)
|
||||
await switchTo('default')
|
||||
await expect.poll(guestState).toMatchObject({ cookies: ['default-cookie'] })
|
||||
await switchTo(created.result.profile.id)
|
||||
await expect.poll(guestState).toMatchObject({ cookies: ['isolated-cookie'] })
|
||||
} finally {
|
||||
await client?.dispose()
|
||||
await host.dispose()
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user