mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(browser): suspend and restore idle desktop tabs (#48080)
This commit is contained in:
committed by
GitHub
Unverified
parent
fcafe82cdc
commit
2dea1f3d0e
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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?.()
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user