diff options
| author | Shoubhit Dash <[email protected]> | 2026-02-17 18:40:39 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-17 07:10:39 -0600 |
| commit | 3dfbb7059345350fdcb3f45fe9a44697c08a040a (patch) | |
| tree | 644fb1b8c958c02651640883fa3b935beea3a3f6 /packages/app/src/context | |
| parent | 07947bab7d7f164ae5b46038deadda2284e97025 (diff) | |
| download | opencode-3dfbb7059345350fdcb3f45fe9a44697c08a040a.tar.gz opencode-3dfbb7059345350fdcb3f45fe9a44697c08a040a.zip | |
fix(app): recover state after sse reconnect and harden sse streams (#13973)
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/global-sdk.tsx | 48 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 5 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/event-reducer.test.ts | 14 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync/event-reducer.ts | 2 |
4 files changed, 67 insertions, 2 deletions
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 3f93b76a7..c7f7708e6 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -2,9 +2,14 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup } from "solid-js" +import z from "zod" import { usePlatform } from "./platform" import { useServer } from "./server" +const abortError = z.object({ + name: z.literal("AbortError"), +}) + export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ name: "GlobalSDK", init: () => { @@ -93,12 +98,35 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo let streamErrorLogged = false const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) + const aborted = (error: unknown) => abortError.safeParse(error).success + + let attempt: AbortController | undefined + const HEARTBEAT_TIMEOUT_MS = 15_000 + let heartbeat: ReturnType<typeof setTimeout> | undefined + const resetHeartbeat = () => { + if (heartbeat) clearTimeout(heartbeat) + heartbeat = setTimeout(() => { + attempt?.abort() + }, HEARTBEAT_TIMEOUT_MS) + } + const clearHeartbeat = () => { + if (!heartbeat) return + clearTimeout(heartbeat) + heartbeat = undefined + } void (async () => { while (!abort.signal.aborted) { + attempt = new AbortController() + const onAbort = () => { + attempt?.abort() + } + abort.signal.addEventListener("abort", onAbort) try { const events = await eventSdk.global.event({ + signal: attempt.signal, onSseError: (error) => { + if (aborted(error)) return if (streamErrorLogged) return streamErrorLogged = true console.error("[global-sdk] event stream error", { @@ -109,7 +137,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo }, }) let yielded = Date.now() + resetHeartbeat() for await (const event of events.stream) { + resetHeartbeat() streamErrorLogged = false const directory = event.directory ?? "global" const payload = event.payload @@ -130,7 +160,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo await wait(0) } } catch (error) { - if (!streamErrorLogged) { + if (!aborted(error) && !streamErrorLogged) { streamErrorLogged = true console.error("[global-sdk] event stream failed", { url: server.url, @@ -138,6 +168,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo error, }) } + } finally { + abort.signal.removeEventListener("abort", onAbort) + attempt = undefined + clearHeartbeat() } if (abort.signal.aborted) return @@ -145,7 +179,19 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } })().finally(flush) + const onVisibility = () => { + if (typeof document === "undefined") return + if (document.visibilityState !== "visible") return + attempt?.abort() + } + if (typeof document !== "undefined") { + document.addEventListener("visibilitychange", onVisibility) + } + onCleanup(() => { + if (typeof document !== "undefined") { + document.removeEventListener("visibilitychange", onVisibility) + } abort.abort() flush() }) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 62c7eb66e..ec5efc675 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -270,6 +270,11 @@ function createGlobalSync() { setGlobalStore("project", next) }, }) + if (event.type === "server.connected" || event.type === "global.disposed") { + for (const directory of Object.keys(children.children)) { + queue.push(directory) + } + } return } diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index ad63f3c20..ab7f99cef 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -116,6 +116,20 @@ describe("applyGlobalEvent", () => { expect(refreshCount).toBe(1) }) + + test("handles server.connected by triggering refresh", () => { + let refreshCount = 0 + applyGlobalEvent({ + event: { type: "server.connected" }, + project: [], + refresh: () => { + refreshCount += 1 + }, + setGlobalProject() {}, + }) + + expect(refreshCount).toBe(1) + }) }) describe("applyDirectoryEvent", () => { diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 66fcac66d..48ac0fea1 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -20,7 +20,7 @@ export function applyGlobalEvent(input: { setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void refresh: () => void }) { - if (input.event.type === "global.disposed") { + if (input.event.type === "global.disposed" || input.event.type === "server.connected") { input.refresh() return } |
