diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a934d07e2..9a883369a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,10 @@ "vscode": { "extensions": [ "harmoniqs.amicode" - ] + ], + "settings": { + "amicode.opencodePort": 43117 + } } } } diff --git a/docs/adr/0005-server-boot-id-and-sse-resilience.md b/docs/adr/0005-server-boot-id-and-sse-resilience.md new file mode 100644 index 000000000..73f36362a --- /dev/null +++ b/docs/adr/0005-server-boot-id-and-sse-resilience.md @@ -0,0 +1,121 @@ +# ADR 0005: Server Boot-ID and SSE Connection Resilience + +## Status + +Accepted + +## Context + +The opencode web app (`packages/app`) connects to the local server via +Server-Sent Events (SSE). When the server restarts — due to a container rebuild, +explicit restart command, or process crash — the SSE stream disconnects and the +client retries every 250 ms. + +The fundamental issue is an asymmetry in persistence lifetimes: the webview's +localStorage lives on the **host machine** (or in VS Code Server's profile storage) +and survives container rebuilds, process restarts, and window reloads. The server +port, however, is **ephemeral** — determined at startup by whichever port happens +to be free. There is no stable identity linking "this localStorage scope" to "this +server instance." The extension host is the only component that knows the correct +server URL at all times (it spawned the process), making it the authoritative +source for runtime connection parameters. The webview should receive these from the +host, not persist and re-read them independently. + +Three problems existed: + +1. **No restart detection**: the `server.connected` SSE event carried + `properties: {}`. The client could not distinguish "same server, reconnected" + from "different server boot on the same port" from "stale URL, server gone." + +2. **Stale URL persistence**: the web app persisted a `defaultServerUrl` in + localStorage that could override `location.origin`. In the Amicode webview + (iframe), `location.origin` is always correct — the override caused the SSE + loop to connect to a dead port indefinitely. + +3. **Infinite retry burn**: the SSE reconnect loop retried forever with no + escalation. A genuinely unreachable server consumed CPU and produced no user + feedback. + +## Decision + +### Server-side: boot-ID generation + +- A new module `src/server/boot-id.ts` generates a `crypto.randomUUID()` on each + `Server.listen()` call and exports it via `BootId.get()`. +- Both SSE handlers (instance at `/api/event` and global at `/global/event`) emit + `{ bootId: BootId.get() }` in the `server.connected` event's `properties`. +- Each `listen()` call (including restarts) produces a fresh ID. + +### Client-side: stale URL prevention + +- In `entry.tsx`, the `getDefaultUrl()` function is gated: when running inside + the Amicode webview (`inAmicode()`), the `defaultServerUrl` localStorage key is + never consulted. `location.origin` is used unconditionally. + +### Client-side: reconnect escalation + +- A `consecutiveFailures` counter in `server-sdk.tsx` tracks connection failures. +- After 10 consecutive failures (2.5 seconds), the SSE loop breaks with a warning + log. The `streamStatus` signal remains `"disconnected"` — the ConnectionBanner + surfaces this to the user. +- A page visibility cycle (`pagehide` → `pageshow`) restarts the loop, providing + a user-initiated recovery path. + +### Client-side: boot-ID persistence and mismatch detection + +- The server store (`packages/app/src/context/server.tsx`) gains a + `lastBootId: Record` field, persisted in localStorage alongside + the existing server list and project state. +- On each `server.connected` event, the client extracts `properties.bootId`, + compares it to the persisted value, logs a warning on mismatch, and persists the + new value. +- The existing `server-sync.tsx` logic already triggers a full refresh (session + list refetch, directory re-bootstrap) on `server.connected` — the boot-ID + mismatch log provides additional observability for debugging. + +## Consequences + +- Servers that restart on the same port (the common case with + `amicode.opencodePort = 43117`) reconnect seamlessly: the SSE loop retries, + connects, receives the new boot-ID, and triggers a refresh. +- Servers on a different port after restart are handled by the extension host's + panel-recreation mechanism (see amicode ADR 0008). +- The 10-failure abort prevents infinite CPU burn on genuinely unreachable servers. +- Future: the abort will be upgraded to self-healing (post `server-url-changed` to + self and redirect) once Phase 4's `AmicodeServerBridge` listener is stable. +- The `lastBootId` persistence enables detecting restarts that happened while the + webview was closed — on next open, the first `server.connected` event's boot-ID + won't match, and the full refresh path fires. + +## Alternatives Considered + +### Boot-ID as HTTP response header on all responses + +Adding `X-OpenCode-Boot-ID` to every HTTP response via a global middleware. This +would benefit non-SSE clients (REST API callers, CLI) but adds middleware +complexity with no immediate gain for the webview client, which connects +exclusively via SSE. The `BootId` module is importable by any future middleware +should this be needed. + +### Self-healing reconnect via postMessage + +After the failure threshold, the SSE loop posts a `server-url-changed` message to +itself, which the `AmicodeServerBridge` handles by updating the server store and +reconnecting. This provides seamless recovery without user intervention. Deferred +until Phase 4's bridge is validated — documented in +`plans/followup-self-healing-reconnect.md`. + +### Forced page reload on failure threshold + +Calling `window.location.reload()` after 10 consecutive failures. Guarantees +recovery (Phase 2 ensures the fresh load uses `location.origin`) but loses all +in-memory state (open editors, scroll positions, draft prompts). Rejected as too +disruptive for a recoverable failure. + +### Port 0 (OS-assigned) with readback + +Changing the extension to pass port `0` and read back the actual bound port from +the server's response. Eliminates port collisions but requires restructuring the +terminal launch protocol and does not address the stale-URL problem (the webview +still needs to discover the new port). Deferred — the fixed-port default (43117) +is the primary mitigation. diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 002090491..d14a606e9 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -58,6 +58,7 @@ import { usePlatform } from "@/context/platform" import { setPendingAutoSend } from "@/pages/new-session/new-session-draft-controller" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" +import { inAmicode } from "@/utils/amicode-bridge" import { SettingsProvider, useSettings } from "@/context/settings" import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" @@ -480,6 +481,28 @@ function AmicodeNavigateBridge() { return null } +/** Phase 4: extension-host → webview URL push. When the extension restarts the + * server on a different port (ephemeral mode), it recreates the panel (so + * location.origin is already correct). For same-port restarts, it posts + * server-url-changed as a "restart happened" signal — the SSE reconnect loop + * and boot-ID detection handle the actual state refresh. This component is the + * fallback safety net: if the URL in the message differs from location.origin, + * the panel was NOT recreated and we redirect to the new URL. */ +function AmicodeServerBridge() { + if (!inAmicode()) return null + const onMsg = (e: MessageEvent) => { + const d = e.data as { source?: string; kind?: string; url?: string } | undefined + if (d?.source !== "amicode" || d.kind !== "server-url-changed" || !d.url) return + // Same origin: server restarted on same port. SSE reconnect handles it. + if (d.url === location.origin || new URL(d.url).origin === location.origin) return + // Different origin: panel should have been recreated, but wasn't. Redirect. + window.location.href = d.url + location.pathname + location.search + } + window.addEventListener("message", onMsg) + onCleanup(() => window.removeEventListener("message", onMsg)) + return null +} + export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -682,6 +705,7 @@ export function AppInterface(props: { root={(routerProps) => ( + diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 1425a53fd..1ac0182a9 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -188,6 +188,7 @@ type ServerSDKBase = { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() + const serverCtx = useServer() const abort = new AbortController() const eventFetch = (() => { @@ -253,6 +254,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } let streamErrorLogged = false + let consecutiveFailures = 0 + const MAX_CONSECUTIVE_FAILURES = 10 const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) let attempt: AbortController | undefined let run: Promise | undefined @@ -297,11 +300,32 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS ? (await eventSdk.global.event({ signal: attempt.signal, onSseError })).stream : eventApi.event.subscribe({ signal: attempt.signal }) setStreamStatus("connected") + consecutiveFailures = 0 let yielded = Date.now() for await (const event of events) { streamErrorLogged = false const legacy = "payload" in event if (legacy && event.payload.type === "sync") continue + + // Boot-ID detection: compare the server's boot identifier on each + // server.connected event to detect restarts across reconnections. + const eventType = legacy ? event.payload.type : event.type + if (eventType === "server.connected") { + const props = legacy ? event.payload.properties : (event as { properties?: Record }).properties + const newBootId = typeof props?.bootId === "string" ? props.bootId : undefined + if (newBootId) { + const previousBootId = serverCtx.getBootId(scope) + if (previousBootId && previousBootId !== newBootId) { + console.warn("[server-sdk] server restarted (boot-ID changed)", { + previous: previousBootId, + current: newBootId, + scope, + }) + } + serverCtx.setBootId(newBootId, scope) + } + } + const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) if (enqueueServerEvent(queue, { directory, payload })) schedule() @@ -311,7 +335,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS await wait(0) } } catch (error) { - if (!isStreamClosed(error, attempt?.signal)) setStreamStatus("disconnected") + if (!isStreamClosed(error, attempt?.signal)) { + setStreamStatus("disconnected") + consecutiveFailures++ + } if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) { streamErrorLogged = true console.error("[global-sdk] event stream failed", { @@ -327,6 +354,13 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS if (abort.signal.aborted || !started || generation !== active) return await wait(RECONNECT_DELAY_MS) + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.warn("[server-sdk] server unreachable after", MAX_CONSECUTIVE_FAILURES, "retries — SSE loop stopped", { + url: server.http.url, + }) + break + } } })().finally(() => { if (run !== current) return diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 7062f96ea..f90118cf0 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -276,6 +276,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( projects: {} as Record, lastProject: {} as Record, recentlyClosed: {} as Record, + lastBootId: {} as Record, }), ) @@ -361,6 +362,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( ...projects, forServer: projectsForServer, }, + getBootId(serverScope?: string) { + return store.lastBootId[serverScope ?? scope()] + }, + setBootId(bootId: string, serverScope?: string) { + setStore("lastBootId", serverScope ?? scope(), bootId) + }, } }, }) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 73562db86..e1d0b862c 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -13,6 +13,7 @@ import { installGlobalClipboardFallback } from "@/utils/global-clipboard" import { installWebviewContextMenu } from "@/utils/webview-context-menu" import { webZoom } from "@/utils/web-zoom" import { authFromToken } from "@/utils/server" +import { inAmicode } from "@/utils/amicode-bridge" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -155,8 +156,14 @@ const getCurrentUrl = () => { } const getDefaultUrl = () => { - const lsDefault = readDefaultServerUrl() - if (lsDefault) return lsDefault + // In the Amicode webview (iframe), location.origin is always the correct + // server URL because the iframe IS served by the running server. Never let a + // stale localStorage override win over it — that causes the "no GUI response" + // bug when the server restarts on a different port. + if (!inAmicode()) { + const lsDefault = readDefaultServerUrl() + if (lsDefault) return lsDefault + } return getCurrentUrl() } diff --git a/packages/opencode/src/server/boot-id.ts b/packages/opencode/src/server/boot-id.ts new file mode 100644 index 000000000..e617fb1fb --- /dev/null +++ b/packages/opencode/src/server/boot-id.ts @@ -0,0 +1,16 @@ +import { randomUUID } from "node:crypto" + +/** A random identifier generated fresh on each `refresh()` call. Clients persist + * this alongside the server URL and detect restarts by comparing it to the + * `bootId` emitted in the `server.connected` SSE event. */ +let _bootId: string | undefined + +export function refresh() { + _bootId = randomUUID() +} + +export function get(): string | undefined { + return _bootId +} + +export * as BootId from "./boot-id" diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts index 4f24fbb4b..8e2a8691f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts @@ -1,6 +1,7 @@ import { EventV2Bridge } from "@/event-v2-bridge" import { InstanceState } from "@/effect/instance-state" import { GlobalBus } from "@/bus/global" +import { BootId } from "@/server/boot-id" import { EventV2 } from "@opencode-ai/core/event" import { Effect, Queue } from "effect" import * as Stream from "effect/Stream" @@ -67,7 +68,7 @@ function eventResponse(events: EventV2.Interface) { yield* Effect.logInfo("event connected") return HttpServerResponse.stream( - Stream.make({ id: eventID(), type: "server.connected", properties: {} }).pipe( + Stream.make({ id: eventID(), type: "server.connected", properties: { bootId: BootId.get() } }).pipe( Stream.concat(output.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts index c1f588d5a..a0924fcef 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts @@ -1,6 +1,7 @@ import { Config } from "@/config/config" import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global" import { EffectBridge } from "@/effect/bridge" +import { BootId } from "@/server/boot-id" import { EventV2 } from "@opencode-ai/core/event" import { Installation } from "@/installation" import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle" @@ -46,7 +47,7 @@ function eventResponse() { ) return HttpServerResponse.stream( - Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: {} } }).pipe( + Stream.make({ payload: { id: EventV2.ID.create(), type: "server.connected", properties: { bootId: BootId.get() } } }).pipe( Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))), Stream.map(eventData), Stream.pipeThroughChannel(Sse.encode()), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index e6d20fca5..fb5ad060b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -7,6 +7,7 @@ import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { createServer } from "node:http" import { MDNS } from "./mdns" +import { BootId } from "./boot-id" import * as AmicodeConnections from "./amicode/connections" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" @@ -72,6 +73,7 @@ export async function openapi() { export let url: URL | undefined export async function listen(opts: ListenOptions): Promise { + BootId.refresh() const listener = await Effect.runPromise(listenEffect(opts)) return { hostname: listener.hostname,