diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 6c0cdc26eeb..608c7092c71 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -895,6 +895,7 @@ export const dict = { "session.browser.address": "Browser address", "session.browser.replaced": "Browser control moved to another desktop window.", + "session.browser.suspended": "Browser suspended. Interact with this session to reconnect.", "session.browser.address.placeholder": "Enter a URL", "titlebar.update": "Update", diff --git a/packages/app/src/runtime/platform/browser-pane.ts b/packages/app/src/runtime/platform/browser-pane.ts index c70bf890add..e02ecb0eb9c 100644 --- a/packages/app/src/runtime/platform/browser-pane.ts +++ b/packages/app/src/runtime/platform/browser-pane.ts @@ -1,7 +1,7 @@ import type { Browser } from "@opencode/plugin-browser/rpc" export type BrowserPaneEndpoint = Readonly<{ url: string; username?: string; password?: string }> -export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint }> +export type BrowserPaneTarget = Readonly<{ sessionID: string; endpoint: BrowserPaneEndpoint; restore?: Browser.State }> export type BrowserPaneLayout = { tabID: Browser.TabID visible: boolean diff --git a/packages/app/src/session/browser/attachments.ts b/packages/app/src/session/browser/attachments.ts index cf120f7f75f..7f9dae3fdb1 100644 --- a/packages/app/src/session/browser/attachments.ts +++ b/packages/app/src/session/browser/attachments.ts @@ -1,30 +1,27 @@ -import { batch, createEffect, createMemo, getOwner, onCleanup, runWithOwner } from "solid-js" +import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode/ui/context" import type { Browser } from "@opencode/plugin-browser/rpc" import { useLanguage } from "@/runtime/i18n/language" -import type { BrowserPaneCommand, BrowserPaneRegistration, BrowserPaneState } from "@/runtime/platform/browser-pane" +import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane" import { usePlatform } from "@/runtime/platform/platform" import type { useServer } from "@/runtime/server/current" import { useSettings } from "@/settings/model" import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs" +import { useCurrentRoute } from "@/shell/state/layout" +import { createEventListener } from "@solid-primitives/event-listener" +import { createBrowserConnection, type BrowserConnectionState } from "./connection" type Server = ReturnType -export type BrowserAttachment = { - registration?: BrowserPaneRegistration - browser: BrowserPaneState - error?: string -} +export type BrowserAttachment = BrowserConnectionState type Live = { server: Server sessionID: string /** Shell tab that owns this attachment once seen; it may route to a child session later. */ tab?: string - registration?: BrowserPaneRegistration - retry?: ReturnType - attempts: number + connection: ReturnType dispose: () => void } @@ -38,6 +35,7 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider const settings = useSettings() const language = useLanguage() const shellTabs = useTabs() + const route = useCurrentRoute() const owner = getOwner() const [store, setStore] = createStore>({}) // Servers whose plugin lacks the browser RPC; sessions on them stop retrying. @@ -71,6 +69,20 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider }) onCleanup(() => Array.from(live.keys()).forEach(close)) + const wakeCurrent = () => { + if (document.visibilityState !== "visible") return + const current = route() + if (current.type !== "session") return + live.get(`${current.server}\n${current.sessionId}`)?.connection.wake() + } + // These are edges, not a reactive dependency on suspended state: eviction while the window + // remains focused must not immediately reopen the browser and defeat resource cleanup. + createEffect(on(route, wakeCurrent)) + createEventListener(window, "focus", wakeCurrent) + createEventListener(document, "visibilitychange", wakeCurrent) + createEventListener(document, "pointerdown", wakeCurrent) + createEventListener(document, "keydown", wakeCurrent) + return { enabled, supported: (server: Server) => !unsupported[server.key], @@ -80,61 +92,48 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider if (live.has(id)) return const pane = platform.browserPane if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return - const entry: Live = { server, sessionID, attempts: 0, dispose: () => undefined } - live.set(id, entry) - setStore(id, { browser: null }) - const register = () => { - if (entry.registration || live.get(id) !== entry) return - // The server's shared transport follows a restarted sidecar's port whether or not any route - // for this session is mounted; the connection captured at attach time may predate it. - const endpoint = { ...server.conn.http, url: server.ctx.sdk.url } - const registration = pane.register({ sessionID, endpoint }, (event) => { - if (live.get(id) !== entry) return - if (event.type === "focus") return focus.get(id)?.forEach((listener) => listener(event.tabID)) - if (event.error === "browser.pane.unsupported") { + const connection = createBrowserConnection({ + pane, + // Resolve the current port at every wake, including after sidecar replacement. + target: () => ({ sessionID, endpoint: { ...server.conn.http, url: server.ctx.sdk.url } }), + focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)), + change: (state) => { + if (state.error === "browser.pane.unsupported") { setUnsupported(server.key, true) return close(id) } - if (event.error === "browser.pane.replaced") { - registration.close() - entry.registration = undefined - return setStore(id, { - registration: undefined, - browser: null, - error: language.t("session.browser.replaced"), - }) - } - // The desktop dropped the attachment (server restart, attach race). Re-register so the - // agent's browser tool comes back without a reload. - if (event.error === "browser.pane.registration.closed") { - registration.close() - entry.registration = undefined - setStore(id, { registration: undefined, browser: null, error: undefined }) - entry.retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** entry.attempts++)) - return - } - if (event.state) entry.attempts = 0 - batch(() => { - setStore(id, "browser", reconcile(event.state)) - setStore(id, "error", event.error ? language.t("common.requestFailed") : undefined) - }) - }) - entry.registration = registration - setStore(id, { registration, browser: null, error: undefined }) - } + setStore( + id, + reconcile({ + ...state, + error: + state.error === "browser.pane.replaced" + ? language.t("session.browser.replaced") + : state.error + ? language.t("common.requestFailed") + : undefined, + }), + ) + }, + }) + const entry: Live = { server, sessionID, connection, dispose: () => undefined } + live.set(id, entry) + setStore(id, { browser: null, suspended: false }) // A new session appears in the UI before its server-side creation finishes. The listener // belongs to this provider, not to the route effect that happened to call attach(). const data = server.ctx.data - const unsubscribe = runWithOwner(owner, () => + const unsubscribe = runWithOwner(owner, () => [ data.on("session.created", (event) => { - if (event.data.sessionID === sessionID) register() + if (event.data.sessionID === sessionID) connection.wake() }), - ) - if (!data.session.creating(sessionID)) register() + data.on("session.execution.started", (event) => { + if (event.data.sessionID === sessionID) connection.wake() + }), + ]) + if (!data.session.creating(sessionID)) connection.wake() entry.dispose = () => { - unsubscribe?.() - clearTimeout(entry.retry) - entry.registration?.close() + unsubscribe?.forEach((dispose) => dispose()) + connection.dispose() } }, /** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */ @@ -149,9 +148,9 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider } }, command(server: Server, sessionID: string, command: BrowserPaneCommand) { - const registration = live.get(key(server, sessionID))?.registration - if (!registration) return Promise.reject(new Error("browser.pane.unavailable")) - return registration.command(command) + const connection = live.get(key(server, sessionID))?.connection + if (!connection) return Promise.reject(new Error("browser.pane.unavailable")) + return connection.command(command) }, } }, diff --git a/packages/app/src/session/browser/connection.test.ts b/packages/app/src/session/browser/connection.test.ts new file mode 100644 index 00000000000..2eb918d7fbb --- /dev/null +++ b/packages/app/src/session/browser/connection.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test" +import { Browser } from "@opencode/plugin-browser/rpc" +import type { BrowserPaneEvent, BrowserPaneTarget } from "@/runtime/platform/browser-pane" +import { createBrowserConnection, type BrowserConnectionState } from "./connection" + +const tabID = Browser.TabID.make(`tab_${crypto.randomUUID()}`) +const browser: Browser.State = { + tabs: [ + { + id: tabID, + url: "http://localhost:4173/", + title: "Preview", + loading: false, + canGoBack: true, + canGoForward: false, + generation: 3, + }, + ], + focusedTabID: tabID, +} + +function fixture() { + const states: BrowserConnectionState[] = [] + const calls: { + target: BrowserPaneTarget + emit: (event: BrowserPaneEvent) => void + closed: boolean + commands: Browser.Action[] + }[] = [] + const endpoint = { url: "http://localhost:4096" } + const connection = createBrowserConnection({ + target: () => ({ sessionID: "ses_browser", endpoint: { ...endpoint } }), + change: (state) => states.push(state), + focus: () => {}, + pane: { + register(target, emit) { + const call = { target, emit, closed: false, commands: [] as Browser.Action[] } + calls.push(call) + return { + setLayout() {}, + async command(command) { + call.commands.push(command) + }, + close() { + call.closed = true + }, + } + }, + }, + }) + connection.wake() + calls[0].emit({ type: "state", state: browser }) + return { connection, calls, states, endpoint } +} + +test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => { + const app = fixture() + try { + app.calls[0].emit({ type: "state", state: browser, error: "browser.pane.suspended" }) + expect(app.calls[0].closed).toBe(true) + expect(app.states.at(-1)).toMatchObject({ registration: undefined, browser, suspended: true }) + // A suspended attachment must not schedule the one-second transport retry. + await Bun.sleep(1_100) + expect(app.calls).toHaveLength(1) + app.endpoint.url = "http://localhost:4999" + app.connection.wake() + app.connection.wake() + expect(app.calls).toHaveLength(2) + expect(app.calls[1].target).toEqual({ sessionID: "ses_browser", endpoint: app.endpoint, restore: browser }) + expect(app.states.at(-1)?.suspended).toBe(false) + app.calls[0].emit({ type: "state", state: null, error: "browser.pane.registration.closed" }) + expect(app.states.at(-1)?.registration).toBeDefined() + } finally { + app.connection.dispose() + } +}) + +test("a command wakes its attachment once and is not replayed", async () => { + const app = fixture() + try { + app.calls[0].emit({ type: "state", state: browser, error: "browser.pane.suspended" }) + await app.connection.command({ type: "reload", tabID }) + expect(app.calls).toHaveLength(2) + expect(app.calls[1].commands).toEqual([{ type: "reload", tabID }]) + app.connection.dispose() + app.connection.wake() + expect(app.calls[1].closed).toBe(true) + expect(app.calls).toHaveLength(2) + } finally { + app.connection.dispose() + } +}) + +for (const error of ["browser.pane.replaced", "browser.pane.unsupported"]) { + test(`${error} blocks automatic ownership recovery`, () => { + const app = fixture() + try { + app.calls[0].emit({ type: "state", state: null, error }) + app.connection.wake() + expect(app.calls).toHaveLength(1) + expect(app.states.at(-1)?.error).toBe(error) + } finally { + app.connection.dispose() + } + }) +} diff --git a/packages/app/src/session/browser/connection.ts b/packages/app/src/session/browser/connection.ts new file mode 100644 index 00000000000..597dac08ceb --- /dev/null +++ b/packages/app/src/session/browser/connection.ts @@ -0,0 +1,80 @@ +import type { + BrowserPanePlatform, + BrowserPaneRegistration, + BrowserPaneState, + BrowserPaneTarget, +} from "@/runtime/platform/browser-pane" +import type { Browser } from "@opencode/plugin-browser/rpc" + +export type BrowserConnectionState = { + registration?: BrowserPaneRegistration + browser: BrowserPaneState + suspended: boolean + error?: string +} + +// Own native registration, retry, and suspended tab metadata independently of the mounted session route. +export function createBrowserConnection(input: { + pane: BrowserPanePlatform + target: () => BrowserPaneTarget + change: (state: BrowserConnectionState) => void + focus: (tabID: Browser.TabID) => void +}) { + const state: BrowserConnectionState = { browser: null, suspended: false } + let disposed = false + let blocked = false + let attempts = 0 + let retry: ReturnType | undefined + const register = () => { + if (disposed || blocked || state.registration) return + clearTimeout(retry) + const registration = input.pane.register( + { ...input.target(), ...(state.browser ? { restore: state.browser } : {}) }, + (event) => { + if (disposed || state.registration !== registration) return + if (event.type === "focus") return input.focus(event.tabID) + if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") { + blocked = true + registration.close() + state.registration = undefined + state.browser = null + state.error = event.error + input.change({ ...state }) + return + } + if (event.error === "browser.pane.suspended" || event.error === "browser.pane.registration.closed") { + registration.close() + state.registration = undefined + state.suspended = event.error === "browser.pane.suspended" + if (state.suspended && event.state) state.browser = event.state + state.error = undefined + input.change({ ...state }) + // Idle eviction has no retry timer. A user or Session execution wakes it on demand. + if (!state.suspended) retry = setTimeout(register, Math.min(30_000, 1_000 * 2 ** attempts++)) + return + } + if (event.state) attempts = 0 + state.browser = event.state + state.error = event.error + input.change({ ...state }) + }, + ) + state.registration = registration + state.suspended = false + state.error = undefined + input.change({ ...state }) + } + return { + wake: register, + command(command: Browser.Action) { + register() + return state.registration?.command(command) ?? Promise.reject(new Error("browser.pane.unavailable")) + }, + dispose() { + disposed = true + clearTimeout(retry) + state.registration?.close() + state.registration = undefined + }, + } +} diff --git a/packages/app/src/session/browser/model.ts b/packages/app/src/session/browser/model.ts index eeecdfd53a7..60d2a6debc5 100644 --- a/packages/app/src/session/browser/model.ts +++ b/packages/app/src/session/browser/model.ts @@ -27,7 +27,7 @@ export function createSessionBrowser(session: SessionModel) { !server.health?.incompatible && session.isDesktop(), ) - const attached = () => attachment()?.registration !== undefined + const attached = () => !!(attachment()?.registration || attachment()?.browser) const browserTabs = createMemo( () => attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ?? @@ -123,6 +123,7 @@ export function createSessionBrowser(session: SessionModel) { browserTabs().find((tab) => tab.id === attachment()?.browser?.focusedTabID) ?? browserTabs()[0], error: () => local.error ?? attachment()?.error, + suspended: () => attachment()?.suspended ?? false, registration: () => attachment()?.registration, close: (tabID: Browser.TabID) => session.layout.tabs().close(sessionBrowserTab(tabID)), open, diff --git a/packages/app/src/session/browser/pane.tsx b/packages/app/src/session/browser/pane.tsx index 187d1feae47..ed895143f67 100644 --- a/packages/app/src/session/browser/pane.tsx +++ b/packages/app/src/session/browser/pane.tsx @@ -7,19 +7,15 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" import { createEffect, For, on, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" -import type { BrowserPaneRegistration } from "@/runtime/platform/browser-pane" import { usePlatform } from "@/runtime/platform/platform" import type { createSessionBrowser } from "./model" -export function SessionBrowserPane(props: { - registration: BrowserPaneRegistration - browser: ReturnType - visible: boolean -}) { +export function SessionBrowserPane(props: { browser: ReturnType; visible: boolean }) { const platform = usePlatform() const language = useLanguage() const dialog = useDialog() const state = props.browser.active + const registration = props.browser.registration const button = { variant: "ghost", size: "large" } as const const [store, setStore] = createStore({ address: "", @@ -45,7 +41,7 @@ export function SessionBrowserPane(props: { if (!surface) return const tab = state() if (!tab) { - props.registration.setLayout() + registration()?.setLayout() return } const rect = surface.getBoundingClientRect() @@ -71,7 +67,7 @@ export function SessionBrowserPane(props: { paint.fillRect(0, 0, 1, 1) } const rgba = paint?.getImageData(0, 0, 1, 1).data - props.registration.setLayout({ + registration()?.setLayout({ tabID: tab.id, visible, bounds: { x: left, y: top, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }, @@ -99,8 +95,12 @@ export function SessionBrowserPane(props: { () => store.visible, () => props.visible, () => state()?.id, + registration, ], - () => schedule(300), + () => { + layout = undefined + schedule(300) + }, ), ) // ResizeObserver runs after layout in the same frame; measuring here instead of on the next @@ -119,7 +119,7 @@ export function SessionBrowserPane(props: { createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible")) onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) - props.registration.setLayout() + registration()?.setLayout() }) return ( @@ -179,7 +179,13 @@ export function SessionBrowserPane(props: { {props.browser.error()} -
+
+ +

+ {language.t("session.browser.suspended")} +

+
+
) } diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index 60afff8aca0..78bb1ba27e0 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -574,15 +574,7 @@ export function SessionSidePanel(props: { classList={{ hidden: !isSessionBrowserTab(activeTab()) }} inert={!isSessionBrowserTab(activeTab()) || undefined} > - - {(registration) => ( - - )} - +
diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 5b4f3a28901..23563eae221 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -1,8 +1,9 @@ -import { Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect" +import { Context, Duration, Effect, Exit, Layer, LayerMap, MutableHashMap, Option } from "effect" import { LayerNode } from "@opencode/util/effect/layer-node" import { Instance } from "./instance.js" import { Location } from "./location.js" import { LocationServiceMap } from "./location-service-map.js" +import { Rpc } from "./rpc.js" export { LocationServiceMap } from "./location-service-map.js" @@ -16,11 +17,11 @@ export function buildLocationServiceMap( LocationServiceMap.Service, Effect.gen(function* () { const owner = yield* Effect.scope - const booting = MutableHashMap.empty() + const builds = MutableHashMap.empty }>() const inner: LayerMap.LayerMap = yield* LayerMap.make( (ref: Location.Ref) => { - const build = {} - MutableHashMap.set(booting, ref, build) + const build: { close?: Effect.Effect } = {} + MutableHashMap.set(builds, ref, build) return Layer.fromBuild((memoMap, scope) => Effect.suspend(() => Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope), @@ -28,8 +29,12 @@ export function buildLocationServiceMap( Effect.onExit((exit) => { const finish = Effect.suspend(() => { // An explicitly invalidated build must not evict its replacement. - if (Option.getOrUndefined(MutableHashMap.get(booting, ref)) !== build) return Effect.void - MutableHashMap.remove(booting, ref) + if (Option.getOrUndefined(MutableHashMap.get(builds, ref)) !== build) return Effect.void + if (Exit.isSuccess(exit)) { + build.close = Context.get(exit.value, Rpc.Service).close + return Effect.void + } + MutableHashMap.remove(builds, ref) // Evict once per failed build, before its result reaches borrowers. return Exit.isFailure(exit) ? inner.invalidate(ref) : Effect.void }) @@ -54,8 +59,11 @@ export function buildLocationServiceMap( invalidate: (ref: Location.Ref) => Effect.suspend(() => { const key = LocationServiceMap.canonical(ref) - MutableHashMap.remove(booting, key) - return inner.invalidate(key) + const build = Option.getOrUndefined(MutableHashMap.get(builds, key)) + MutableHashMap.remove(builds, key) + // Detach routing first, then end pending RPCs that still borrow the old graph. + // Do not await a boot here: failed/in-flight builds have their own cleanup path. + return inner.invalidate(key).pipe(Effect.andThen(build?.close ?? Effect.void)) }), } // Cached instances borrow their owner instead of retaining its Layer scope. diff --git a/packages/core/src/rpc.ts b/packages/core/src/rpc.ts index 94bd3763799..f7e65a9054a 100644 --- a/packages/core/src/rpc.ts +++ b/packages/core/src/rpc.ts @@ -8,7 +8,7 @@ import { Event } from "@opencode/schema/event" import type { Tool } from "@opencode/schema/tool" import type { StandardSchemaV1 } from "@standard-schema/spec" import { makeLocationNode } from "@opencode/util/effect/app-node" -import { Context, Effect, JsonSchema, Layer, Schema, SchemaRepresentation, Stream } from "effect" +import { Context, Deferred, Effect, JsonSchema, Layer, Schema, SchemaRepresentation, Stream } from "effect" import { Bus } from "./bus.js" import { Location } from "./location.js" import { optional, statics } from "./schema.js" @@ -17,6 +17,7 @@ export interface Interface { readonly register: RpcDomain["register"] readonly client: (definition: D) => RpcClient readonly call: (rpcID: string, method: string, input: unknown) => Effect.Effect + readonly close: Effect.Effect } export class Service extends Context.Service()("@opencode/Rpc") {} @@ -37,6 +38,7 @@ const layer = Layer.effect( const bus = yield* Bus.Service const location = yield* Location.Service const ref = Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }) + const closed = yield* Deferred.make() const callContext = { error: (type: string, message: string, data?: unknown) => new DeclaredError(type, message, data), } @@ -78,9 +80,7 @@ const layer = Layer.effect( registrations.set(definition.id, remaining) }) yield* Effect.acquireRelease( - Effect.sync(() => - registrations.set(definition.id, [...(registrations.get(definition.id) ?? []), entry]), - ), + Effect.sync(() => registrations.set(definition.id, [...(registrations.get(definition.id) ?? []), entry])), () => dispose, ) @@ -90,8 +90,7 @@ const layer = Layer.effect( events: { emit: Effect.fn("Rpc.emit")(function* (...args: Rpc.EventInput) { const registered = events.get(args[0]) - if (!registered) - return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.id}.${args[0]}`)) + if (!registered) return yield* Effect.fail(new Error(`Unknown RPC event: ${definition.id}.${args[0]}`)) const event = registered.event // SAFETY: The public event-schema contract guarantees an object encoded/output type. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion @@ -107,9 +106,10 @@ const layer = Layer.effect( }) const call = Effect.fn("Rpc.call")(function* (rpcID: string, name: string, input: unknown) { - const entry = registrations.get(rpcID)?.at(-1) - if (!entry) + if (yield* Deferred.isDone(closed)) return yield* Effect.fail(failure("rpc.unavailable", `RPC is unavailable: ${rpcID}`)) + const entry = registrations.get(rpcID)?.at(-1) + if (!entry) return yield* Effect.fail(failure("rpc.unavailable", `RPC is unavailable: ${rpcID}`)) if (!Object.hasOwn(entry.definition.methods, name) || !Object.hasOwn(entry.handlers, name)) return yield* Effect.fail(failure("rpc.method_not_found", `Unknown RPC method: ${rpcID}.${name}`)) const method = entry.definition.methods[name] @@ -129,6 +129,12 @@ const layer = Layer.effect( Effect.andThen(Effect.fail(failure("rpc.internal", "RPC call failed"))), ), ), + // A long-lived RPC must release its location when the routing cache invalidates it. + Effect.raceFirst( + Deferred.await(closed).pipe( + Effect.andThen(Effect.fail(failure("rpc.unavailable", `RPC is unavailable: ${rpcID}`))), + ), + ), ) return yield* encode(method.output, result).pipe( Effect.mapError((error) => failure("rpc.invalid_output", errorMessage(error, "Invalid RPC output"))), @@ -164,7 +170,7 @@ const layer = Layer.effect( } as RpcClient } - return Service.of({ register, call, client }) + return Service.of({ register, call, client, close: Deferred.succeed(closed, undefined).pipe(Effect.asVoid) }) }), ) diff --git a/packages/core/test/browser-idle.test.ts b/packages/core/test/browser-idle.test.ts new file mode 100644 index 00000000000..26ad0ae8830 --- /dev/null +++ b/packages/core/test/browser-idle.test.ts @@ -0,0 +1,90 @@ +import { expect } from "bun:test" +import { Context, Deferred, Effect, Fiber, RcMap, Stream } from "effect" +import { Browser } from "@opencode/plugin-browser/rpc" +import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" +import { LayerNode } from "@opencode/util/effect/layer-node" +import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { Global } from "@opencode/util/global" +import { Bus } from "@opencode/core/bus" +import { Database } from "@opencode/core/database/database" +import { Location } from "@opencode/core/location" +import { LocationActivity } from "@opencode/core/location-activity" +import { LocationServiceMap } from "@opencode/core/location-services" +import { Plugin } from "@opencode/core/plugin" +import { Rpc } from "@opencode/core/rpc" +import { AbsolutePath } from "@opencode/core/schema" +import { Session } from "@opencode/core/session" +import { SessionExecution } from "@opencode/core/session/execution" +import { SessionStore } from "@opencode/core/session/store" +import { tempGlobalLayer } from "./fixture/global" +import { offlineModels } from "./fixture/models" +import { tmpdirScoped } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect( + AppNodeBuilder.build( + LayerNode.group([Database.node, Bus.node, Session.node, LocationServiceMap.node, LocationActivity.node]), + [ + Global.node.replace(tempGlobalLayer), + offlineModels, + LocationActivity.node.replace( + makeGlobalNode({ + service: LocationActivity.Service, + layer: LocationActivity.layer({ timeToLive: "2 seconds", sweepInterval: "100 millis" }), + deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node], + }), + ), + ], + ), +) + +it.live( + "idle eviction ends the browser attachment and permits a fresh attachment", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const ref = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) + const locations = yield* LocationServiceMap.Service + const context = yield* locations.contextEffect(ref).pipe(Effect.scoped) + yield* Plugin.awaitActivation.pipe(Effect.provideContext(context)) + const sessions = yield* Session.Service + const session = yield* sessions.create({ location: ref }) + const rpc = Context.get(context, Rpc.Service).client(Browser.Definition) + const attachment = { sessionID: session.id, connectionID: crypto.randomUUID() } + const attached = yield* Deferred.make() + yield* rpc.events.subscribe("control").pipe( + Stream.runForEach((event) => + event.data.type === "attached" ? Deferred.succeed(attached, undefined) : Effect.void, + ), + Effect.forkScoped({ startImmediately: true }), + ) + const pending = yield* rpc + .attach({ ...attachment, version: 4 }) + .pipe(Effect.provide(locations.get(ref)), Effect.flip, Effect.forkScoped) + yield* Deferred.await(attached) + const state = { ...attachment, state: { tabs: [], focusedTabID: null } } + yield* rpc.state(state) + + // Real idle cleanup must end the long-lived request, not leave a second registry behind it. + expect(yield* Fiber.join(pending).pipe(Effect.timeout("10 seconds"))).toMatchObject({ type: "rpc.unavailable" }) + expect(yield* RcMap.has(locations.rcMap, LocationServiceMap.canonical(ref))).toBe(false) + expect(yield* rpc.state(state).pipe(Effect.flip)).toMatchObject({ type: "rpc.unavailable" }) + + const replacement = yield* locations.contextEffect(ref) + yield* Plugin.awaitActivation.pipe(Effect.provideContext(replacement)) + const fresh = Context.get(replacement, Rpc.Service).client(Browser.Definition) + const restored = yield* Deferred.make() + yield* fresh.events.subscribe("control").pipe( + Stream.runForEach((event) => + event.data.type === "attached" ? Deferred.succeed(restored, undefined) : Effect.void, + ), + Effect.forkScoped({ startImmediately: true }), + ) + const resumed = yield* fresh.attach({ ...attachment, version: 4 }).pipe(Effect.forkScoped) + yield* Deferred.await(restored) + yield* fresh.state(state) + expect(resumed.pollUnsafe()).toBeUndefined() + yield* Fiber.interrupt(resumed) + }), + 30_000, +) diff --git a/packages/desktop/src/main/browser-chromium.ts b/packages/desktop/src/main/browser-chromium.ts index d8b8db637a9..e5eb649ae18 100644 --- a/packages/desktop/src/main/browser-chromium.ts +++ b/packages/desktop/src/main/browser-chromium.ts @@ -38,6 +38,7 @@ export function createBrowserPage( fail: () => void popup: (options: Electron.BrowserWindowConstructorOptions) => WebContents initialize?: boolean + restore?: Browser.Tab popupOptions?: Electron.BrowserWindowConstructorOptions }, ) { @@ -82,7 +83,8 @@ export function createBrowserPage( let dialog: { type: string; message: string; defaultValue: string } | null = null let dialogURL = "" let dialogRevision = 0 - let generation = 0 + // The first restored navigation consumes the generation reserved in the unloaded inventory. + let generation = options.restore ? options.restore.generation - 1 : 0 let revision = 0 cdp.on("Page.frameNavigated", ({ frame }) => { documents.set(frame.id, frame.url) @@ -242,7 +244,15 @@ export function createBrowserPage( }) const ready = Promise.all([ files.ready, - ...(options.initialize === false ? [] : [contents.loadURL("about:blank")]), + ...(options.initialize === false + ? [] + : [ + contents.loadURL(normalizeURL(options.restore?.url || "about:blank")).catch((error: Error) => { + if (!options.restore) throw error + // A dev server may have stopped while this page was unloaded. Keep its tab available to retry. + options.publish(error.message) + }), + ]), diagnostics.enable(), cdp.send("Page.enable"), cdp.send("DOM.enable"), diff --git a/packages/desktop/src/main/browser-pane.ts b/packages/desktop/src/main/browser-pane.ts index 21b8c8b7e50..ddd5c70b486 100644 --- a/packages/desktop/src/main/browser-pane.ts +++ b/packages/desktop/src/main/browser-pane.ts @@ -23,6 +23,7 @@ type Entry = { report?: (event: BrowserPaneEvent["event"]) => void cleanup?: () => void pages: Map + tabs: Map focusedTabID: Browser.TabID | null partition: string lastState?: string @@ -48,11 +49,24 @@ export function createBrowserPane() { registered: Promise.withResolvers(), requests: new Map(), pages: new Map(), - focusedTabID: null, + tabs: new Map( + target.restore?.tabs.map((tab) => [ + tab.id, + { + ...tab, + loading: false, + canGoBack: false, + canGoForward: false, + generation: tab.generation + 1, + }, + ]), + ), + focusedTabID: target.restore?.focusedTabID ?? null, partition: `opencode-browser-${crypto.randomUUID()}`, } // "unsupported" means the server has no browser plugin; the renderer stops retrying. - let reason: "browser.pane.unsupported" | "browser.pane.replaced" | undefined + let reason: "browser.pane.unsupported" | "browser.pane.replaced" | "browser.pane.suspended" | undefined + let attached = false const stop = () => close(entry, reason) const navigate = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => { if (event.isMainFrame && !event.isSameDocument) stop() @@ -143,7 +157,10 @@ export function createBrowserPane() { }), ), ) - if (message.type === "attached") return entry.registered.resolve() + if (message.type === "attached") { + attached = true + return entry.registered.resolve() + } if (message.type === "cancel") return entry.requests.get(message.requestID)?.abort.abort() const abort = new AbortController() entry.requests.set(message.requestID, { abort }) @@ -204,6 +221,10 @@ export function createBrowserPane() { Effect.tapError((error) => Effect.sync(() => { const type = error instanceof Object && "type" in error ? error.type : undefined + if (type === "rpc.unavailable" && attached) { + reason = "browser.pane.suspended" + return + } if (type === "rpc.unavailable" || type === "rpc.method_not_found" || type === "rpc.invalid_input") reason = "browser.pane.unsupported" }), @@ -221,13 +242,13 @@ export function createBrowserPane() { layout(win: BrowserWindow, bindingID: string, value?: BrowserPaneLayout) { const entry = owned(win, bindingID) if (!value) return entry.pages.forEach((page) => page.setVisible(false)) - const page = entry.pages.get(value.tabID) - if (!page) return const bounds = value.bounds if (!value.visible || !bounds || bounds.width <= 0 || bounds.height <= 0) { - page.setVisible(false) + entry.pages.get(value.tabID)?.setVisible(false) return } + const page = load(entry, value.tabID) + if (!page) return entry.pages.forEach((other) => { if (other !== page) other.setVisible(false) }) @@ -264,12 +285,15 @@ export function createBrowserPane() { entry.report = undefined entry.requests.forEach((request) => request.abort.abort()) entry.requests.clear() + const suspended = reason === "browser.pane.suspended" + if (suspended) publishState(entry, reason) entry.pages.forEach((page) => { void page.dispose().catch(() => undefined) }) entry.pages.clear() + entry.tabs.clear() entry.focusedTabID = null - publishState(entry, reason) + if (!suspended) publishState(entry, reason) entries.delete(entry.bindingID) entry.registered.reject(new Error("browser.pane.registration.closed")) entry.cleanup?.() @@ -278,7 +302,7 @@ export function createBrowserPane() { async function closePage(entry: Entry, tabID: Browser.TabID, error?: string) { const page = entry.pages.get(tabID) - if (!page) + if (!entry.tabs.has(tabID)) throw new Error( "This tab is no longer available. Call browser.tabs.list({}) and use an existing tabID from this session.", ) @@ -287,15 +311,16 @@ export function createBrowserPane() { if (request.tabID === tabID) request.abort.abort() }) entry.pages.delete(tabID) - if (focused) entry.focusedTabID = entry.pages.keys().next().value ?? null - await page.dispose() + entry.tabs.delete(tabID) + if (focused) entry.focusedTabID = entry.tabs.keys().next().value ?? null + await page?.dispose() publishState(entry, error) } function publishState(entry: Entry, error?: string) { const event = { type: "state" as const, - state: { tabs: Array.from(entry.pages.values(), (page) => page.state()), focusedTabID: entry.focusedTabID }, + state: inventory(entry), ...(error === undefined ? {} : { error }), } const next = JSON.stringify(event) @@ -309,9 +334,28 @@ export function createBrowserPane() { publish(entry, event) } - function create(entry: Entry, initialize = true, popupOptions?: Electron.BrowserWindowConstructorOptions) { + function inventory(entry: Entry): Browser.State { + return { + tabs: Array.from(entry.tabs, ([id, tab]) => entry.pages.get(id)?.state() ?? tab), + focusedTabID: entry.focusedTabID, + } + } + + function load(entry: Entry, tabID: Browser.TabID) { + const page = entry.pages.get(tabID) + if (page) return page + const tab = entry.tabs.get(tabID) + return tab ? create(entry, true, undefined, tab) : undefined + } + + function create( + entry: Entry, + initialize = true, + popupOptions?: Electron.BrowserWindowConstructorOptions, + restore?: Browser.Tab, + ) { if (!entry.network) throw new Error("Browser network is not ready; no tab was opened.") - const id = Browser.TabID.make(`tab_${crypto.randomUUID()}`) + const id = restore?.id ?? Browser.TabID.make(`tab_${crypto.randomUUID()}`) const fail = () => { if (entry.pages.has(id)) void closePage(entry, id, "page_crashed").catch(() => undefined) } @@ -320,6 +364,7 @@ export function createBrowserPane() { partition: entry.partition, network: entry.network, initialize, + restore, popupOptions, fail, publish: (error) => { @@ -332,6 +377,7 @@ export function createBrowserPane() { }, }) entry.pages.set(id, page) + entry.tabs.set(id, restore ?? page.state()) void page.ready .then(() => { if (entry.pages.get(id) === page) publishState(entry) @@ -342,15 +388,11 @@ export function createBrowserPane() { async function execute(entry: Entry, command: Browser.Command, signal: AbortSignal) { const action = command.action - const state = () => ({ - tabs: Array.from(entry.pages.values(), (page) => page.state()), - focusedTabID: entry.focusedTabID, - }) if (signal.aborted) throw new Error( "Browser request was cancelled. Do not repeat a mutating action until you have inspected its outcome.", ) - if (action.type === "tabs.list") return { value: state(), files: [] } + if (action.type === "tabs.list") return { value: inventory(entry), files: [] } if (action.type === "tabs.open") { const page = create(entry) if (action.focus !== false) focus(entry, page.state().id) @@ -362,19 +404,20 @@ export function createBrowserPane() { publishState(entry) return { value: page.state(), files: [] } } - const page = entry.pages.get(action.tabID) - if (!page) + const tab = inventory(entry).tabs.find((tab) => tab.id === action.tabID) + if (!tab) throw new Error( "Browser tab is unavailable. Call browser.tabs.list({}) and use an existing tabID from this session; a closed tab is not replaced automatically.", ) if (action.type === "tabs.focus") { focus(entry, action.tabID) - return { value: page.state(), files: [] } + return { value: tab, files: [] } } if (action.type === "tabs.close") { await closePage(entry, action.tabID) - return { value: state(), files: [] } + return { value: inventory(entry), files: [] } } + const page = entry.pages.get(action.tabID) ?? create(entry, true, undefined, tab) await page.ready const result = await page.execute(command, signal) publishState(entry) diff --git a/packages/desktop/src/shared/ipc-rpc/browser.ts b/packages/desktop/src/shared/ipc-rpc/browser.ts index 813679f5085..f891f3f66f2 100644 --- a/packages/desktop/src/shared/ipc-rpc/browser.ts +++ b/packages/desktop/src/shared/ipc-rpc/browser.ts @@ -9,7 +9,11 @@ const endpoint = Schema.Struct({ username: Schema.optionalKey(text(1_024)), password: Schema.optionalKey(text(4_096)), }) -const target = Schema.Struct({ sessionID: text(256).check(Schema.isStartsWith("ses")), endpoint }) +const target = Schema.Struct({ + sessionID: text(256).check(Schema.isStartsWith("ses")), + endpoint, + restore: Schema.optionalKey(Browser.State), +}) const bounds = Schema.Struct({ x: Schema.Finite, y: Schema.Finite, width: Schema.Finite, height: Schema.Finite }) const channel = Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 255 })) const layout = Schema.Struct({ diff --git a/packages/desktop/test/browser-native.test.ts b/packages/desktop/test/browser-native.test.ts index b4bfcc62b4f..f474c1a1f63 100644 --- a/packages/desktop/test/browser-native.test.ts +++ b/packages/desktop/test/browser-native.test.ts @@ -4,15 +4,15 @@ import path from "node:path" import { tmpdir } from "node:os" // Spawns a real Electron window; CI runners have no display for it. -test.skipIf(!!process.env.CI)( - "browser suite over authenticated HTTP RPC with separate server and desktop storage", - async () => { +test.skipIf(!!process.env.CI).each(["native", "idle"])( + "browser %s suite over authenticated HTTP RPC with separate server and desktop storage", + async (suite) => { const root = await mkdtemp(path.join(tmpdir(), "opencode-browser-suite-")) const output = await mkdtemp(path.resolve(import.meta.dir, "../node_modules/.browser-suite-")) const names = ["home", "config", "data", "cache", "state", "server-files", "client-files", "electron-data"] await Promise.all(names.map((name) => mkdir(path.join(root, name), { recursive: true }))) const built = await Bun.build({ - entrypoints: [path.join(import.meta.dir, "browser/native.ts")], + entrypoints: [path.join(import.meta.dir, `browser/${suite}.ts`)], outdir: output, naming: "native.mjs", target: "node", @@ -34,15 +34,18 @@ test.skipIf(!!process.env.CI)( ELECTRON_RUN_AS_NODE: undefined, } const ready = Promise.withResolvers() - const server = Bun.spawn([process.execPath, path.join(import.meta.dir, "browser/server.ts")], { - cwd: root, - env: { ...environment, TMP: path.join(root, "server-files"), TEMP: path.join(root, "server-files") }, - stdout: "inherit", - stderr: "inherit", - ipc(message: unknown) { - if (typeof message === "string") ready.resolve(message) + const server = Bun.spawn( + [process.execPath, path.join(import.meta.dir, suite === "idle" ? "browser/idle-server.ts" : "browser/server.ts")], + { + cwd: root, + env: { ...environment, TMP: path.join(root, "server-files"), TEMP: path.join(root, "server-files") }, + stdout: "inherit", + stderr: "inherit", + ipc(message: unknown) { + if (typeof message === "string") ready.resolve(message) + }, }, - }) + ) void server.exited.then((code) => ready.reject(new Error(`Fixture server exited: ${code}`))) let native: ReturnType | undefined let proxy: Bun.Server | undefined diff --git a/packages/desktop/test/browser/idle-server.ts b/packages/desktop/test/browser/idle-server.ts new file mode 100644 index 00000000000..e099f0a9351 --- /dev/null +++ b/packages/desktop/test/browser/idle-server.ts @@ -0,0 +1,54 @@ +import path from "node:path" +import { Context, Effect, Layer } from "effect" +import { HttpEffect, HttpRouter, HttpServer } from "effect/unstable/http" +import { LocationServiceMap } from "../../../core/src/location-services" +import { Location } from "../../../core/src/location" +import { AbsolutePath } from "../../../core/src/schema" +import { createRoutes } from "../../../server/src/routes" + +const stopped = Promise.withResolvers() +process.on("message", (message) => { + if (message === "stop") stopped.resolve() +}) +await Effect.gen(function* () { + const context = yield* Layer.build( + createRoutes({ + password: process.env.SMOKE_PASSWORD, + app: { name: "browser-idle-test", version: "test", channel: "test" }, + database: { path: ":memory:" }, + models: { fetch: false }, + fs: { filewatcher: false, fff: false }, + config: { + directory: process.env.OPENCODE_CONFIG_DIR!, + project: false, + content: JSON.stringify({ + plugins: ["-opencode.provider.*", path.join(import.meta.dir, "plugin")], + permissions: [{ action: "*", resource: "*", effect: "allow" }], + }), + }, + }).pipe(Layer.provide(HttpServer.layerServices)), + ) + const locations = Context.get(context, LocationServiceMap.Service) + const handler = Context.get(context, HttpRouter.HttpRouter).asHttpEffect().pipe(HttpEffect.toWebHandlerWith(context)) + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + idleTimeout: 0, + async fetch(request) { + if (new URL(request.url).pathname !== "/__test/evict") return handler(request) + if ( + request.method !== "POST" || + request.headers.get("authorization") !== `Basic ${btoa(`opencode:${process.env.SMOKE_PASSWORD}`)}` + ) + return new Response(null, { status: 401 }) + await Effect.runPromise( + locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(process.env.SMOKE_SERVER_FILES!) })), + ) + return new Response(null, { status: 204 }) + }, + }) + yield* Effect.addFinalizer(() => Effect.sync(() => server.stop(true))) + process.send?.(server.url.href) + yield* Effect.promise(() => stopped.promise) +}).pipe(Effect.scoped, Effect.runPromise) +process.disconnect?.() diff --git a/packages/desktop/test/browser/idle.ts b/packages/desktop/test/browser/idle.ts new file mode 100644 index 00000000000..f72535fc740 --- /dev/null +++ b/packages/desktop/test/browser/idle.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { once } from "node:events" +import { mkdir, writeFile } from "node:fs/promises" +import path from "node:path" +import { app, BrowserWindow, webContents } from "electron" +import { Effect, Fiber, Stream } from "effect" +import { OpenCode } from "@opencode/client" +import { createBrowserPane } from "../../src/main/browser-pane" +import { waitFor } from "../../src/main/browser/cdp" +import { bindIpcEvents, ipcEventStream } from "../../src/main/ipc-events" +import { createBrowserConnection, type BrowserConnectionState } from "../../../app/src/session/browser/connection" +import type { BrowserPaneEvent } from "../../../app/src/runtime/platform/browser-pane" +import { Smoke } from "./contract" + +async function main() { + app.setPath("userData", path.join(process.env.SMOKE_ROOT!, "electron-data")) + app.on("window-all-closed", () => {}) + await app.whenReady() + const loads = new Map() + const web = createServer((request, response) => { + const url = request.url ?? "/" + loads.set(url, (loads.get(url) ?? 0) + 1) + response.setHeader("content-type", "text/html") + response.setHeader("cache-control", "no-store") + response.end( + `Idle recovery ${url}

Browser connection restored

Page ${url}

`, + ) + }) + await once(web.listen(0, "127.0.0.1"), "listening") + const address = web.address() + assert(address && typeof address !== "string") + const url = `http://127.0.0.1:${address.port}` + const endpoint = { url: process.env.SMOKE_URL!, password: process.env.SMOKE_PASSWORD } + const headers = { authorization: `Basic ${Buffer.from(`opencode:${endpoint.password}`).toString("base64")}` } + const client = OpenCode.make({ baseUrl: endpoint.url, headers }) + const location = { directory: process.env.SMOKE_SERVER_FILES! } + const session = await client.session.create({ title: "Idle browser", location }) + const pane = createBrowserPane() + const win = new BrowserWindow({ show: false, width: 1100, height: 800, webPreferences: { sandbox: true } }) + await win.loadURL("about:blank") + win.showInactive() + const listeners = new Map void>() + const unbind = await Effect.runPromise(bindIpcEvents(win.webContents.id)) + const events = Effect.runFork( + ipcEventStream(win.webContents.id).pipe( + Stream.runForEach((event) => + Effect.sync(() => { + if (event._tag === "BrowserPaneEvent") listeners.get(event.bindingID)?.(event.event) + }), + ), + ), + ) + const states: BrowserConnectionState[] = [] + const connection = createBrowserConnection({ + target: () => ({ sessionID: session.id, endpoint }), + change: (state) => states.push(state), + focus: () => {}, + pane: { + register(target, listener) { + const bindingID = crypto.randomUUID() + listeners.set(bindingID, listener) + const ready = pane.register(win, bindingID, target) + void ready.catch(() => {}) + return { + setLayout: (layout) => { + void ready.then(() => pane.layout(win, bindingID, layout)) + }, + command: (command) => ready.then(() => pane.command(win, bindingID, command)), + close: () => { + listeners.delete(bindingID) + void ready.then(() => pane.close(win, bindingID)).catch(() => {}) + }, + } + }, + }, + }) + const signal = new AbortController().signal + try { + await connection.command({ type: "tabs.open", url: `${url}/first` }) + await connection.command({ type: "tabs.open", url: `${url}/second`, focus: false }) + await waitFor(() => states.at(-1)?.browser?.tabs.length === 2, signal) + const saved = states.at(-1)?.browser + assert(saved) + assert.equal(loads.get("/first"), 1) + assert.equal(loads.get("/second"), 1) + const evicted = await fetch(new URL("/__test/evict", endpoint.url), { method: "POST", headers }) + assert.equal(evicted.status, 204) + await waitFor(() => states.at(-1)?.suspended === true && win.contentView.children.length === 0, signal) + assert.deepEqual( + states.at(-1)?.browser?.tabs.map((tab) => tab.id), + saved.tabs.map((tab) => tab.id), + ) + assert.equal(listeners.size, 0) + + connection.wake() + await connection.command({ type: "tabs.list" }) + assert.equal(win.contentView.children.length, 0, "reconnecting must not load background pages") + const tabID = saved.tabs[0].id + states.at(-1)?.registration?.setLayout({ tabID, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } }) + await waitFor(async () => { + const page = webContents.getAllWebContents().find((page) => page.getURL() === `${url}/first`) + return ( + !!page && + !page.isLoading() && + (await page.executeJavaScript("document.body.textContent")).includes("Browser connection restored") + ) + }, signal) + assert.equal(loads.get("/first"), 2) + assert.equal(loads.get("/second"), 1, "an inactive tab must stay unloaded") + await waitFor(() => states.at(-1)?.browser?.tabs.find((tab) => tab.id === tabID)?.loading === false, signal) + const result = await client + .rpc(Smoke) + .execute( + { sessionID: session.id, code: `return await tools.browser.snapshot({tabID: ${JSON.stringify(tabID)}})` }, + { location }, + ) + assert(!result.error, result.output) + assert(result.output.includes("Browser connection restored")) + const background = await client + .rpc(Smoke) + .execute( + { + sessionID: session.id, + code: `return await tools.browser.snapshot({tabID: ${JSON.stringify(saved.tabs[1].id)}})`, + }, + { location }, + ) + assert(!background.error, background.output) + assert.equal(loads.get("/second"), 2, "an agent can load an inactive restored tab without user focus") + if (process.env.SMOKE_EVIDENCE) { + await mkdir(process.env.SMOKE_EVIDENCE, { recursive: true }) + const page = webContents.getAllWebContents().find((page) => page.getURL() === `${url}/first`) + assert(page) + await writeFile(path.join(process.env.SMOKE_EVIDENCE, "after.png"), (await page.capturePage()).toPNG()) + } + assert((states.at(-1)?.browser?.tabs[0].generation ?? 0) > saved.tabs[0].generation) + console.log("PASS idle eviction releases native views, preserves tab IDs, and restores only the requested page") + } finally { + connection.dispose() + await pane.dispose() + await Effect.runPromise(Fiber.interrupt(events)) + await Effect.runPromise(unbind) + win.destroy() + web.closeAllConnections() + await new Promise((resolve) => web.close(() => resolve())) + app.quit() + } +} + +main().catch((error) => { + console.error(error) + app.exit(1) +})