Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion packages/app/src/context/server-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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 = {
Expand Down
26 changes: 24 additions & 2 deletions packages/app/src/context/server-sdk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
type ServerSDKBase = {
server: ServerConnection.Any
Expand Down Expand Up @@ -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", {
Expand Down
Loading