diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index fea525217..f0d490145 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { + adaptServerEvent, + applySseError, + coalesceServerEvents, + enqueueServerEvent, + resumeStreamAfterPageShow, +} from "./server-sdk" import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" @@ -26,6 +32,40 @@ describe("resumeStreamAfterPageShow", () => { }) }) +describe("applySseError", () => { + const spy = () => { + const calls = { disconnect: 0, abort: 0 } + return { + calls, + disconnect: () => calls.disconnect++, + abort: () => calls.abort++, + } + } + + test("a real stream failure ABORTS the attempt, not just marks it disconnected", () => { + // The regression: the v1 stream's iterator never throws or completes on + // failure, so the reconnect loop only comes round if the attempt is + // aborted. Marking disconnected without aborting leaves the client dead on + // a server that is already back. + const s = spy() + expect(applySseError({ closed: false, ...s })).toBe(true) + expect(s.calls).toEqual({ disconnect: 1, abort: 1 }) + }) + + test("an already-closed stream is left alone — that is our own abort coming back", () => { + const s = spy() + expect(applySseError({ closed: true, ...s })).toBe(false) + expect(s.calls).toEqual({ disconnect: 0, abort: 0 }) + }) + + test("repeated failures keep aborting — recovery must not depend on a first-error latch", () => { + const s = spy() + applySseError({ closed: false, ...s }) + applySseError({ closed: false, ...s }) + expect(s.calls.abort).toBe(2) + }) +}) + describe("adaptServerEvent", () => { test("preserves V2 events while adapting permission requests for existing consumers", () => { const current = { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 1425a53fd..8fada1d13 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -163,6 +163,24 @@ export function resumeStreamAfterPageShow(_event: PageTransitionEvent, start: () start() } +/** What an SSE stream error must do, extracted so the ABORT — the part that + * reads as redundant and is easy to delete — is covered by a test. + * + * The v1 event stream reports failures through its `onSseError` callback and + * then simply stops yielding: the async iterator neither throws nor completes. + * The reconnect loop only comes round when that iterator ends, so without the + * abort it parks inside `for await` forever and the client stays disconnected + * from a server that is already back — opencode#132's stuck banner, and every + * solver switch since #221, which restarts the server by design. + * + * Returns whether this was a real failure, so the caller keeps its log latch. */ +export function applySseError(input: { closed: boolean; disconnect: () => void; abort: () => void }): boolean { + if (input.closed) return false + input.disconnect() + input.abort() + return true +} + type ServerEventEmitter = ReturnType> type ServerSDKBase = { server: ServerConnection.Any @@ -282,8 +300,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS try { const kind = await protocol const onSseError = (error: unknown) => { - if (isStreamClosed(error, attempt?.signal)) return - setStreamStatus("disconnected") + const real = applySseError({ + closed: isStreamClosed(error, attempt?.signal), + disconnect: () => setStreamStatus("disconnected"), + abort: () => attempt?.abort(), + }) + if (!real) return if (streamErrorLogged) return streamErrorLogged = true console.error("[global-sdk] event stream error", {