Skip to content
Open
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
3 changes: 2 additions & 1 deletion packages/app/src/runtime/server/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
info(message, data) {
if (message !== "event stream disconnected") return
const managed = server.type === "plugin" ? server.managed : !!reconnect
console.info("[global-sdk] event stream disconnected", { url: transport.url, managed, ...data })
// Debug exports keep the console as text, where an object reads "[object Object]".
console.info("[global-sdk] event stream disconnected", JSON.stringify({ url: transport.url, managed, ...data }))
},
},
})
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/runtime/server/request-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export function createRequestQueue(input: {
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
// Debug exports keep the console as text, where an object reads "[object Object]".
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, JSON.stringify(data)))
const inflight = new Set<Entry>()
const waiting: Array<{ entry: Entry; start: () => void }> = []
let warned = -Infinity
Expand Down
19 changes: 16 additions & 3 deletions packages/client/src/solid/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export type ClientConnectionOptions = {

const connectTimeout = 2_000
const reconnectDelay = 1_000
// Each attempt that fails before the stream connects (server offline, unreachable or rejecting us) doubles the wait
// before the next one, up to this delay. Chromium keeps an aborted attempt's CORS preflight queued on the origin's six
// sockets until TCP gives up minutes later, so retrying an unreachable server every few seconds piles them up. A
// resync retries at once.
const maxReconnectDelay = 30_000
const connectionHistoryLimit = 50
export const defaultIdleTimeout = 45_000
// Longer than one server keepalive interval: a stream that is silent this long when the page
Expand All @@ -51,6 +56,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
let flushTimer: ReturnType<typeof setTimeout> | undefined
let stream: AbortController | undefined
let current: AbortController | undefined
let pause: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
Expand Down Expand Up @@ -178,7 +184,12 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
forced = false
continue
}
await wait(reconnectDelay, controller.signal)
pause = new AbortController()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TUI (packages/tui/src/context/client.tsx) uses this loop too, and it has no pageLifecycle or online/visibility events to trigger resync. With an explicit --server and no managed service, a server that is down for a while (for example during an upgrade restart) is now picked up up to ~30 s after it comes back, instead of within ~1 s. Is that intended for the TUI, or should the backoff (or a lower cap) be enabled only where something can call resync?

await wait(
Math.min(reconnectDelay * 2 ** (attempt - 1), maxReconnectDelay),
AbortSignal.any([controller.signal, pause.signal]),
)
pause = undefined
}
}

Expand Down Expand Up @@ -208,9 +219,11 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie
setConnection({ status: "connecting", attempt: 0, error: undefined })
}

// Drop the live request so the reconnect loop replaces it now instead of waiting for the idle watchdog.
// Drop the live request so the reconnect loop replaces it now instead of waiting for the idle watchdog. Between failed
// attempts, end the pause instead so the next attempt starts now.
function resync(reason: string) {
if (!started || connection.status !== "connected") return
if (!started) return
if (connection.status !== "connected") return pause?.abort()
options.log?.info?.("event stream resync", { reason, idle: Date.now() - lastActivity })
forced = true
current?.abort(new Error(reason))
Expand Down
48 changes: 47 additions & 1 deletion packages/client/test/solid-connection.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, test } from "bun:test"
import { afterEach, expect, jest, test } from "bun:test"
import { createRoot } from "solid-js"
import { createClientConnection } from "../src/solid"
import { OpenCode, type OpenCodeEvent } from "../src/promise"
Expand Down Expand Up @@ -59,6 +59,8 @@ function setup(input: ReturnType<typeof server>, idleTimeout: number) {
}))
}

afterEach(() => jest.useRealTimers())

async function until(check: () => boolean, timeout = 2_000) {
const deadline = Date.now() + timeout
while (!check()) {
Expand Down Expand Up @@ -163,6 +165,50 @@ test.each([
}
})

test("a server that keeps failing before the stream connects is retried less often until a resync", async () => {
jest.useFakeTimers()
const requests: number[] = []
// Each resolves when the loop logs that failure, just before it starts the pause before the next attempt.
const disconnects = Array.from({ length: 9 }, () => Promise.withResolvers<void>())
const disconnected = (count: number) => disconnects[count - 1].promise
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async () => {
requests.push(Date.now())
throw new TypeError("Failed to fetch")
},
})
const ctx = createRoot((dispose) => ({
dispose,
connection: createClientConnection(api, {
flushInterval: 0,
onEvent: () => undefined,
log: {
info(message, data) {
if (message === "event stream disconnected") disconnects[Number(data?.attempt) - 1]?.resolve()
},
},
}),
}))
try {
await disconnected(1)
for (const count of [2, 3, 4, 5, 6, 7, 8]) {
jest.advanceTimersToNextTimer()
await disconnected(count)
}
expect(requests.slice(1).map((at, index) => at - requests[index])).toEqual([
1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000,
])
expect(ctx.connection.status()).toBe("reconnecting")

ctx.connection.internal.resync("Network connection restored")
await disconnected(9)
expect(requests.at(-1)).toBe(requests.at(-2))
} finally {
ctx.dispose()
}
})

test("a reconnect does not ask the volatile event stream to replay", async () => {
const fake = server()
const ctx = setup(fake, 10_000)
Expand Down
Loading