fix(app): recover state after sse reconnect and harden sse streams (#13973)
This commit is contained in:
@@ -2,9 +2,14 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
|
|||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { batch, onCleanup } from "solid-js"
|
import { batch, onCleanup } from "solid-js"
|
||||||
|
import z from "zod"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
|
const abortError = z.object({
|
||||||
|
name: z.literal("AbortError"),
|
||||||
|
})
|
||||||
|
|
||||||
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
|
||||||
name: "GlobalSDK",
|
name: "GlobalSDK",
|
||||||
init: () => {
|
init: () => {
|
||||||
@@ -93,12 +98,35 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
|
|
||||||
let streamErrorLogged = false
|
let streamErrorLogged = false
|
||||||
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
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 () => {
|
void (async () => {
|
||||||
while (!abort.signal.aborted) {
|
while (!abort.signal.aborted) {
|
||||||
|
attempt = new AbortController()
|
||||||
|
const onAbort = () => {
|
||||||
|
attempt?.abort()
|
||||||
|
}
|
||||||
|
abort.signal.addEventListener("abort", onAbort)
|
||||||
try {
|
try {
|
||||||
const events = await eventSdk.global.event({
|
const events = await eventSdk.global.event({
|
||||||
|
signal: attempt.signal,
|
||||||
onSseError: (error) => {
|
onSseError: (error) => {
|
||||||
|
if (aborted(error)) return
|
||||||
if (streamErrorLogged) return
|
if (streamErrorLogged) return
|
||||||
streamErrorLogged = true
|
streamErrorLogged = true
|
||||||
console.error("[global-sdk] event stream error", {
|
console.error("[global-sdk] event stream error", {
|
||||||
@@ -109,7 +137,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
let yielded = Date.now()
|
let yielded = Date.now()
|
||||||
|
resetHeartbeat()
|
||||||
for await (const event of events.stream) {
|
for await (const event of events.stream) {
|
||||||
|
resetHeartbeat()
|
||||||
streamErrorLogged = false
|
streamErrorLogged = false
|
||||||
const directory = event.directory ?? "global"
|
const directory = event.directory ?? "global"
|
||||||
const payload = event.payload
|
const payload = event.payload
|
||||||
@@ -130,7 +160,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
await wait(0)
|
await wait(0)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!streamErrorLogged) {
|
if (!aborted(error) && !streamErrorLogged) {
|
||||||
streamErrorLogged = true
|
streamErrorLogged = true
|
||||||
console.error("[global-sdk] event stream failed", {
|
console.error("[global-sdk] event stream failed", {
|
||||||
url: server.url,
|
url: server.url,
|
||||||
@@ -138,6 +168,10 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
error,
|
error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
abort.signal.removeEventListener("abort", onAbort)
|
||||||
|
attempt = undefined
|
||||||
|
clearHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (abort.signal.aborted) return
|
if (abort.signal.aborted) return
|
||||||
@@ -145,7 +179,19 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
|||||||
}
|
}
|
||||||
})().finally(flush)
|
})().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(() => {
|
onCleanup(() => {
|
||||||
|
if (typeof document !== "undefined") {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibility)
|
||||||
|
}
|
||||||
abort.abort()
|
abort.abort()
|
||||||
flush()
|
flush()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -270,6 +270,11 @@ function createGlobalSync() {
|
|||||||
setGlobalStore("project", next)
|
setGlobalStore("project", next)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
if (event.type === "server.connected" || event.type === "global.disposed") {
|
||||||
|
for (const directory of Object.keys(children.children)) {
|
||||||
|
queue.push(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,20 @@ describe("applyGlobalEvent", () => {
|
|||||||
|
|
||||||
expect(refreshCount).toBe(1)
|
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", () => {
|
describe("applyDirectoryEvent", () => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function applyGlobalEvent(input: {
|
|||||||
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
|
setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
}) {
|
}) {
|
||||||
if (input.event.type === "global.disposed") {
|
if (input.event.type === "global.disposed" || input.event.type === "server.connected") {
|
||||||
input.refresh()
|
input.refresh()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
log.info("global event connected")
|
log.info("global event connected")
|
||||||
|
c.header("X-Accel-Buffering", "no")
|
||||||
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
stream.writeSSE({
|
stream.writeSSE({
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
@@ -82,7 +84,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
}
|
}
|
||||||
GlobalBus.on("event", handler)
|
GlobalBus.on("event", handler)
|
||||||
|
|
||||||
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
stream.writeSSE({
|
stream.writeSSE({
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
@@ -92,7 +94,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}, 30000)
|
}, 10_000)
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
stream.onAbort(() => {
|
stream.onAbort(() => {
|
||||||
|
|||||||
@@ -501,6 +501,8 @@ export namespace Server {
|
|||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
log.info("event connected")
|
log.info("event connected")
|
||||||
|
c.header("X-Accel-Buffering", "no")
|
||||||
|
c.header("X-Content-Type-Options", "nosniff")
|
||||||
return streamSSE(c, async (stream) => {
|
return streamSSE(c, async (stream) => {
|
||||||
stream.writeSSE({
|
stream.writeSSE({
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
@@ -517,7 +519,7 @@ export namespace Server {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send heartbeat every 30s to prevent WKWebView timeout (60s default)
|
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
stream.writeSSE({
|
stream.writeSSE({
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
@@ -525,7 +527,7 @@ export namespace Server {
|
|||||||
properties: {},
|
properties: {},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}, 30000)
|
}, 10_000)
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
stream.onAbort(() => {
|
stream.onAbort(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user