summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-02-17 18:40:39 +0530
committerGitHub <[email protected]>2026-02-17 07:10:39 -0600
commit3dfbb7059345350fdcb3f45fe9a44697c08a040a (patch)
tree644fb1b8c958c02651640883fa3b935beea3a3f6 /packages/app/src
parent07947bab7d7f164ae5b46038deadda2284e97025 (diff)
downloadopencode-3dfbb7059345350fdcb3f45fe9a44697c08a040a.tar.gz
opencode-3dfbb7059345350fdcb3f45fe9a44697c08a040a.zip
fix(app): recover state after sse reconnect and harden sse streams (#13973)
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/context/global-sdk.tsx48
-rw-r--r--packages/app/src/context/global-sync.tsx5
-rw-r--r--packages/app/src/context/global-sync/event-reducer.test.ts14
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts2
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
}