fix(ui): bound OpenCode read requests so half-open sockets cannot freeze bootstrap (#2470)

The SDK client fetch wrapper now applies a 30s timeout to non-streaming
reads. Without it, a socket that neither resolves nor rejects keeps the
directory bootstrap concurrency slot busy forever and the UI stays on
"loading sessions". Long-lived streams (POST prompts, the /event SSE)
are explicitly excluded so they are not cut off mid-flight.

The normalized "request timed out" error is added to the retry
allowlist alongside undici's "terminated" (the exact failure observed
in #2470 when undici tears down a half-open upstream connection); both
are transient while the managed OpenCode process restarts. Caller-
initiated aborts keep their original error shape so a user-cancelled
request is not retried.

Tests cover: GET timeout fires after the bound, POST is not timed out,
/event SSE is not timed out, caller abort wins, AbortError is not
retried, and the SDK normalized error is retried 3x.
This commit is contained in:
herjarsa
2026-08-17 11:37:20 +02:00
parent 7ef6441bf3
commit 39bb71a62b
5 changed files with 264 additions and 3 deletions
+53 -2
View File
@@ -199,10 +199,61 @@ const createTimeoutSignal = (timeoutMs: number): { signal: AbortSignal; cleanup:
};
};
const createRuntimeOpencodeClient = (config: { baseUrl: string; directory?: string }): OpencodeClient => {
/**
* Upper bound for non-streaming OpenCode read requests. Without it, a socket
* that neither resolves nor rejects (the half-open state described in #2470)
* keeps the bootstrap concurrency slot busy forever and the UI stays on
* "loading sessions". Long-lived streams (POST prompts, the /event SSE) are
* explicitly excluded in {@link createRuntimeOpencodeClient}.
*/
const OPENCODE_REQUEST_TIMEOUT_MS = 30_000;
const isEventStreamUrl = (input: string | URL | Request): boolean => {
const url = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
return url.includes('/event');
};
type RuntimeOpencodeClientConfig = {
baseUrl: string;
directory?: string;
/** Read-request timeout in ms. Overridable so tests can use short value. */
requestTimeoutMs?: number;
};
export const createRuntimeOpencodeClient = (config: RuntimeOpencodeClientConfig): OpencodeClient => {
const requestTimeoutMs = config.requestTimeoutMs ?? OPENCODE_REQUEST_TIMEOUT_MS;
return createOpencodeClient({
...config,
fetch: runtimeFetch,
fetch: async (input: string | URL | Request, init?: RequestInit) => {
const method = String(
init?.method ?? (input instanceof Request ? input.method : 'GET'),
).toUpperCase();
if (isEventStreamUrl(input) || method === 'POST') {
return runtimeFetch(input, init);
}
const timeout = createTimeoutSignal(requestTimeoutMs);
const callerSignal = init?.signal;
const supportsAny = typeof AbortSignal !== 'undefined'
&& typeof (AbortSignal as { any?: unknown }).any === 'function';
const signal: AbortSignal = callerSignal && supportsAny
? (AbortSignal as typeof AbortSignal & { any: (signals: AbortSignal[]) => AbortSignal })
.any([callerSignal, timeout.signal])
: (callerSignal ?? timeout.signal);
try {
return await runtimeFetch(input, { ...init, signal });
} catch (error) {
if (timeout.signal.aborted && !callerSignal?.aborted) {
throw new Error(`OpenCode request timed out after ${requestTimeoutMs}ms`);
}
throw error;
} finally {
timeout.cleanup();
}
},
});
};