diff --git a/packages/app/src/runtime/server/client.tsx b/packages/app/src/runtime/server/client.tsx index a54a947f2a28..85d2e54ab89f 100644 --- a/packages/app/src/runtime/server/client.tsx +++ b/packages/app/src/runtime/server/client.tsx @@ -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 })) }, }, }) diff --git a/packages/app/src/runtime/server/request-queue.ts b/packages/app/src/runtime/server/request-queue.ts index 2d7659648605..fb809e3a374d 100644 --- a/packages/app/src/runtime/server/request-queue.ts +++ b/packages/app/src/runtime/server/request-queue.ts @@ -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() const waiting: Array<{ entry: Entry; start: () => void }> = [] let warned = -Infinity diff --git a/packages/client/src/solid/connection.ts b/packages/client/src/solid/connection.ts index 01e6b75b28c1..4f8467e5c015 100644 --- a/packages/client/src/solid/connection.ts +++ b/packages/client/src/solid/connection.ts @@ -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 @@ -51,6 +56,7 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie let flushTimer: ReturnType | undefined let stream: AbortController | undefined let current: AbortController | undefined + let pause: AbortController | undefined let run: Promise | undefined let started = false let generation = 0 @@ -178,7 +184,12 @@ export function createClientConnection(initialApi: OpenCodeClient, options: Clie forced = false continue } - await wait(reconnectDelay, controller.signal) + pause = new AbortController() + await wait( + Math.min(reconnectDelay * 2 ** (attempt - 1), maxReconnectDelay), + AbortSignal.any([controller.signal, pause.signal]), + ) + pause = undefined } } @@ -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)) diff --git a/packages/client/test/solid-connection.test.ts b/packages/client/test/solid-connection.test.ts index 60219ed73ea3..c5b1fa912b6b 100644 --- a/packages/client/test/solid-connection.test.ts +++ b/packages/client/test/solid-connection.test.ts @@ -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" @@ -59,6 +59,8 @@ function setup(input: ReturnType, idleTimeout: number) { })) } +afterEach(() => jest.useRealTimers()) + async function until(check: () => boolean, timeout = 2_000) { const deadline = Date.now() + timeout while (!check()) { @@ -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()) + 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)