fix(browser): suspend and restore idle desktop tabs (#48080)

This commit is contained in:
Luke Parker
2026-09-09 15:15:11 +10:00
committed by GitHub
Unverified
parent fcafe82cdc
commit 2dea1f3d0e
17 changed files with 692 additions and 135 deletions
+1
View File
@@ -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",
@@ -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
+58 -59
View File
@@ -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<typeof useServer>
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<typeof setTimeout>
attempts: number
connection: ReturnType<typeof createBrowserConnection>
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<Record<string, BrowserAttachment | undefined>>({})
// 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)
},
}
},
@@ -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()
}
})
}
@@ -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<typeof setTimeout> | 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
},
}
}
+2 -1
View File
@@ -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,
+17 -11
View File
@@ -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<typeof createSessionBrowser>
visible: boolean
}) {
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; 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()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base" />
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={props.browser.suspended()}>
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
{language.t("session.browser.suspended")}
</p>
</Show>
</div>
</aside>
)
}
@@ -574,15 +574,7 @@ export function SessionSidePanel(props: {
classList={{ hidden: !isSessionBrowserTab(activeTab()) }}
inert={!isSessionBrowserTab(activeTab()) || undefined}
>
<Show when={props.browser.registration()} keyed>
{(registration) => (
<SessionBrowserPane
registration={registration}
browser={props.browser}
visible={isSessionBrowserTab(activeTab())}
/>
)}
</Show>
<SessionBrowserPane browser={props.browser} visible={isSessionBrowserTab(activeTab())} />
</div>
</Show>
+16 -8
View File
@@ -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<Location.Ref, object>()
const builds = MutableHashMap.empty<Location.Ref, { close?: Effect.Effect<void> }>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build = {}
MutableHashMap.set(booting, ref, build)
const build: { close?: Effect.Effect<void> } = {}
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.
+15 -9
View File
@@ -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: <D extends Rpc.Definition>(definition: D) => RpcClient<D, Rpc.SystemError, never, unknown>
readonly call: (rpcID: string, method: string, input: unknown) => Effect.Effect<unknown, Rpc.Failure>
readonly close: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@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<void>()
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<D>) {
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<D, Rpc.SystemError, never, unknown>
}
return Service.of({ register, call, client })
return Service.of({ register, call, client, close: Deferred.succeed(closed, undefined).pipe(Effect.asVoid) })
}),
)
+90
View File
@@ -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<void>()
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<void>()
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,
)
+12 -2
View File
@@ -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"),
+65 -22
View File
@@ -23,6 +23,7 @@ type Entry = {
report?: (event: BrowserPaneEvent["event"]) => void
cleanup?: () => void
pages: Map<Browser.TabID, BrowserPage>
tabs: Map<Browser.TabID, Browser.Tab>
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)
@@ -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({
+15 -12
View File
@@ -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<string>()
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<typeof Bun.spawn> | undefined
let proxy: Bun.Server<undefined> | undefined
@@ -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<void>()
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?.()
+154
View File
@@ -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<string, number>()
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(
`<!doctype html><title>Idle recovery ${url}</title><body style="font:24px system-ui;padding:48px;background:#fff;color:#222"><h1>Browser connection restored</h1><p>Page ${url}</p><input aria-label="Draft" value="Initial value"></body>`,
)
})
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<string, (event: BrowserPaneEvent) => 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<void>((resolve) => web.close(() => resolve()))
app.quit()
}
}
main().catch((error) => {
console.error(error)
app.exit(1)
})