diff --git a/packages/ui/src/apps/AppEffects.tsx b/packages/ui/src/apps/AppEffects.tsx index 10775a9d..6114c8d8 100644 --- a/packages/ui/src/apps/AppEffects.tsx +++ b/packages/ui/src/apps/AppEffects.tsx @@ -22,13 +22,16 @@ const SyncOptimisticBridge: React.FC = () => { const sync = useSync(); const addRef = React.useRef(sync.optimistic.add); const removeRef = React.useRef(sync.optimistic.remove); + const confirmRef = React.useRef(sync.optimistic.confirm); addRef.current = sync.optimistic.add; removeRef.current = sync.optimistic.remove; + confirmRef.current = sync.optimistic.confirm; React.useEffect(() => { setOptimisticRefs( (input) => addRef.current(input), (input) => removeRef.current(input), + (input) => confirmRef.current(input), ); }, []); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 59867ef0..f5012dc6 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -154,9 +154,6 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? if (!payload) { return; } - if (!payload.primaryText && payload.primaryAttachments.length === 0) { - return; - } // Use send config captured at queue time; fall back to current config const captured = payload.sendConfig; @@ -176,9 +173,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? agent: resolved.agent, variant: resolved.variant, }); - - const removeFromQueue = useMessageQueueStore.getState().removeFromQueue; - removeFromQueue(sessionId, payload.queuedMessageId); + useMessageQueueStore.getState().removeFromQueue(sessionId, payload.queuedMessageId); } catch (error) { console.warn('[queue] queued auto-send failed:', error); } finally { diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index 0e794aa0..71d1c0ec 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, mock, test } from 'bun:test'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; type ConfigResponse = { data: Record }; @@ -6,6 +6,15 @@ type ConfigResponse = { data: Record }; const configResolvers: Array<(response: ConfigResponse) => void> = []; let configCalls = 0; +const promptAsyncCalls: unknown[][] = []; +const promptAsyncResults: Array = []; + +const promptAsyncMock = mock(async (...args: unknown[]) => { + promptAsyncCalls.push(args); + const next = promptAsyncResults.shift(); + if (next instanceof Error) throw next; + return next ?? { response: new Response(null, { status: 200 }) }; +}); mock.module('@opencode-ai/sdk/v2', () => ({ createOpencodeClient: mock(() => ({ @@ -17,6 +26,9 @@ mock.module('@opencode-ai/sdk/v2', () => ({ }); }), }, + session: { + promptAsync: promptAsyncMock, + }, })), })); @@ -47,6 +59,11 @@ mock.module('@/lib/startupTrace', () => ({ const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`); +beforeEach(() => { + promptAsyncCalls.length = 0; + promptAsyncResults.length = 0; +}); + describe('opencodeClient getConfig cache', () => { test('cleared stale in-flight requests do not repopulate cache or delete newer in-flight requests', async () => { const first = opencodeClient.getConfig('/workspace/project'); @@ -72,3 +89,54 @@ describe('opencodeClient getConfig cache', () => { expect(configCalls).toBe(2); }); }); + +describe('opencodeClient prompt retry behavior', () => { + const sendPrompt = (providerID = 'anthropic') => opencodeClient.sendMessage({ + id: 'ses_1', + providerID, + modelID: 'claude-sonnet', + text: 'hello', + }); + + test('does not retry 504 prompt responses because the POST may already be accepted', async () => { + promptAsyncResults.push({ response: new Response('gateway timeout', { status: 504 }) }); + + let error: unknown = null; + try { + await sendPrompt('anthropic-504'); + } catch (caught) { + error = caught; + } + + expect(promptAsyncCalls.length).toBe(1); + expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (504)'); + }); + + test('does not retry transport failures because the tunnel may have lost only the response', async () => { + promptAsyncResults.push(new TypeError('Failed to fetch')); + + let error: unknown = null; + try { + await sendPrompt('anthropic-network'); + } catch (caught) { + error = caught; + } + + expect(promptAsyncCalls.length).toBe(1); + expect(error instanceof Error ? error.message : String(error)).toContain('Failed to fetch'); + }); + + test('does not retry 503 prompt responses because proxy errors can be ambiguous too', async () => { + promptAsyncResults.push({ response: new Response('starting', { status: 503 }) }); + + let error: unknown = null; + try { + await sendPrompt('anthropic-503'); + } catch (caught) { + error = caught; + } + + expect(promptAsyncCalls.length).toBe(1); + expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (503)'); + }); +}); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 040c85d1..90ac657d 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -22,8 +22,6 @@ import { assertProviderCircuitClosed, recordProviderSuccess, recordProviderError, - shouldRetry, - getRetryDelayMs, } from "./provider-tracker"; // Use relative path by default (works with both dev and nginx proxy server) @@ -120,12 +118,6 @@ const ascendingId = (prefix: "msg"): string => { return `${prefix}_${hex}${randomBase62(ID_RANDOM_LENGTH)}`; }; -const isRetryableFetchError = (error: unknown): boolean => { - if (error instanceof DOMException && error.name === 'AbortError') return true; - if (error instanceof TypeError) return true; - return false; -}; - const ensureAbsoluteBaseUrl = (candidate: string): string => { const normalized = typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : "/api"; @@ -764,8 +756,8 @@ class OpencodeService { }; directory?: string | null; }): Promise { - // Reuse one client-side message ID across retries. The server accepts this - // as the real user message ID, making ambiguous network retries idempotent. + // Use the optimistic/client-generated ID as the real user message ID so SSE + // can reconcile the echoed server message in-place. const messageId = params.messageId ?? ascendingId("msg"); // Build parts array using SDK types (TextPartInput | FilePartInput) plus lightweight agent parts @@ -848,74 +840,55 @@ class OpencodeService { assertProviderCircuitClosed(params.providerID); - let response!: Response; + let response: Response; - for (let attempt = 0; attempt < 3; attempt++) { - try { - const result = await this.client.session.promptAsync({ - sessionID: params.id, - ...(requestDirectory ? { directory: requestDirectory } : {}), - model: { - providerID: params.providerID, - modelID: params.modelID, - }, - agent: params.agent, - variant: params.variant, - messageID: messageId, - ...(params.delivery ? { delivery: params.delivery } : {}), - ...(params.format ? { format: params.format } : {}), - parts, - }); - if (result.response instanceof Response) { - response = result.response; - } else if (result.error) { - const status = (result as SdkResult).response?.status || 500; - response = new Response(JSON.stringify(result.error), { status }); - } else { - response = new Response(JSON.stringify(result.data ?? true), { status: 200 }); - } - } catch (error) { - if (attempt < 2 && isRetryableFetchError(error)) { - const delay = getRetryDelayMs(attempt); - console.warn( - `[prompt] fetch failed for ${params.providerID}/${params.modelID} (attempt ${attempt + 1}/3), retrying in ${delay}ms`, - (error as Error)?.message - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - recordProviderError(params.providerID); - throw error; + try { + const result = await this.client.session.promptAsync({ + sessionID: params.id, + ...(requestDirectory ? { directory: requestDirectory } : {}), + model: { + providerID: params.providerID, + modelID: params.modelID, + }, + agent: params.agent, + variant: params.variant, + messageID: messageId, + ...(params.delivery ? { delivery: params.delivery } : {}), + ...(params.format ? { format: params.format } : {}), + parts, + }); + if (result.response instanceof Response) { + response = result.response; + } else if (result.error) { + const status = (result as SdkResult).response?.status || 500; + response = new Response(JSON.stringify(result.error), { status }); + } else { + response = new Response(JSON.stringify(result.data ?? true), { status: 200 }); } - - if (response.ok) { - recordProviderSuccess(params.providerID); - return messageId; - } - - if (shouldRetry(params.providerID, response.status, attempt)) { - const delay = getRetryDelayMs(attempt); - console.warn( - `[prompt] ${response.status} for ${params.providerID}/${params.modelID} (attempt ${attempt + 1}/3), retrying in ${delay}ms` - ); - await new Promise((resolve) => setTimeout(resolve, delay)); - continue; - } - - let detail = ''; - try { - detail = await response.text(); - } catch { - // ignore - } - const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : ''; - const error = new Error(`Failed to send message (${response.status})${suffix}`); - recordProviderError(params.providerID, response.status); + } catch (error) { + // Do not retry prompt_async after a transport failure: through a remote + // tunnel the POST may already be running server-side even though the + // client lost the response. + recordProviderError(params.providerID); throw error; } - // Defensive fallback — all loop paths return/throw, but TypeScript - // control flow analysis cannot prove exhaustiveness without this. - throw new Error('Failed to send message after retries'); + + if (response.ok) { + recordProviderSuccess(params.providerID); + return messageId; + } + + let detail = ''; + try { + detail = await response.text(); + } catch { + // ignore + } + const suffix = detail && detail.trim().length > 0 ? `: ${detail.trim()}` : ''; + const error = new Error(`Failed to send message (${response.status})${suffix}`) as Error & { status?: number }; + error.status = response.status; + recordProviderError(params.providerID, response.status); + throw error; } async sendCommand(params: { diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index a583bc0a..42daf31e 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -10,6 +10,7 @@ let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status? let questionReplyError: unknown | null = null let questionRejectError: unknown | null = null let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {} +let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] } const globalUpsertedSessions: unknown[] = [] const mockScopedClient = { @@ -41,7 +42,7 @@ const mockSdk = { session: { messages: mock((params: Record) => { replyCalls.push({ method: "session.messages", params }) - return Promise.resolve({ data: [] }) + return Promise.resolve(sessionMessagesResult) }), revert: mock((params: Record) => { replyCalls.push({ method: "session.revert", params }) @@ -343,6 +344,7 @@ describe("optimisticSend target directory", () => { beforeEach(() => { replyCalls.length = 0 scopedClientDirectories.length = 0 + sessionMessagesResult = { data: [] } }) test("passes the prompt directory to optimistic state during session switch races", async () => { @@ -438,6 +440,95 @@ describe("optimisticSend target directory", () => { expect((optimisticRemove as unknown as OptimisticRemoveCall).sessionID).toBe("session-race") expect(targetStore.getState().session_status["session-race"]?.type).toBe("idle") }) + + test("confirms an ambiguous send failure with a recent message refetch", async () => { + const targetStore = createStore({}) + const childStores = createChildStores([["/target/project", targetStore]]) + let optimisticRemove: OptimisticRemoveCall | null = null + let optimisticConfirm: OptimisticRemoveCall | null = null + let sentMessageID = "" + + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + () => {}, + (input) => { + optimisticRemove = input + }, + (input) => { + optimisticConfirm = input + }, + ) + + await optimisticSend({ + sessionId: "session-confirmed", + directory: "/target/project", + content: "hello", + providerID: "provider", + modelID: "model", + send: async (messageID) => { + sentMessageID = messageID + sessionMessagesResult = { + data: [{ + info: { id: messageID, role: "user", sessionID: "session-confirmed", time: { created: 1 } } as Message, + parts: [{ id: "server-part", type: "text", text: "hello" } as Part], + }], + } + const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number } + error.status = 504 + throw error + }, + }) + + expect(optimisticRemove).toBe(null) + expect((optimisticConfirm as OptimisticRemoveCall | null)?.messageID).toBe(sentMessageID) + expect(replyCalls.find((call) => call.method === "session.messages")?.params.limit).toBe(30) + expect(targetStore.getState().message["session-confirmed"]?.[0]?.id).toBe(sentMessageID) + expect(targetStore.getState().part[sentMessageID]?.[0]?.id).toBe("server-part") + }) + + test("rolls back an ambiguous send failure when recent messages do not contain the sent ID", async () => { + const targetStore = createStore({}) + const childStores = createChildStores([["/target/project", targetStore]]) + let optimisticRemove: OptimisticRemoveCall | null = null + let optimisticConfirm: OptimisticRemoveCall | null = null + + const { optimisticSend, setActionRefs, setOptimisticRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/target/project") + setOptimisticRefs( + () => {}, + (input) => { + optimisticRemove = input + }, + (input) => { + optimisticConfirm = input + }, + ) + + let caught: unknown = null + try { + await optimisticSend({ + sessionId: "session-missing", + directory: "/target/project", + content: "hello", + providerID: "provider", + modelID: "model", + send: async () => { + const error = new Error("Failed to send message (504): gateway timeout") as Error & { status?: number } + error.status = 504 + throw error + }, + }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + expect((optimisticRemove as OptimisticRemoveCall | null)?.sessionID).toBe("session-missing") + expect(optimisticConfirm).toBe(null) + expect(replyCalls.filter((call) => call.method === "session.messages").every((call) => call.params.limit === 30)).toBe(true) + expect(targetStore.getState().session_status["session-missing"]?.type).toBe("idle") + }) }) describe("respondToPermission passes directory", () => { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 90a4e89d..d6af6b60 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -29,6 +29,9 @@ import { } from "@/lib/sessionReviewMetadata" const MESSAGE_REFETCH_LIMIT = 100 +const SEND_CONFIRMATION_REFETCH_LIMIT = 30 +const SEND_CONFIRMATION_REFETCH_ATTEMPTS = 2 +const SEND_CONFIRMATION_REFETCH_RETRY_MS = 150 const MESSAGE_REFETCH_SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const UNREVERT_REFETCH_ATTEMPTS = 3 const UNREVERT_REFETCH_RETRY_MS = 150 @@ -39,9 +42,11 @@ let _childStores: ChildStoreManager | null = null let _getDirectory: () => string = () => "" type OptimisticAddInput = { sessionID: string; directory?: string | null; message: Message; parts: Part[] } type OptimisticRemoveInput = { sessionID: string; directory?: string | null; messageID: string } +type OptimisticConfirmInput = OptimisticRemoveInput let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null +let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -100,9 +105,11 @@ export function setActionRefs( export function setOptimisticRefs( add: (input: OptimisticAddInput) => void, remove: (input: OptimisticRemoveInput) => void, + confirm?: (input: OptimisticConfirmInput) => void, ) { _optimisticAdd = add _optimisticRemove = remove + _optimisticConfirm = confirm ?? null } function sdk() { @@ -189,6 +196,36 @@ function connectionLostError(): Error { return new Error(`Connection lost${suffix}. Please wait for reconnection.`) } +function getErrorStatus(error: unknown): number | null { + if (!error || typeof error !== "object") return null + const direct = (error as { status?: unknown }).status + if (typeof direct === "number") return direct + const response = (error as { response?: { status?: unknown } }).response + return typeof response?.status === "number" ? response.status : null +} + +function isAmbiguousSendFailure(error: unknown): boolean { + const status = getErrorStatus(error) + if (status === 503 || status === 504 || status === 408) return true + if (error instanceof TypeError) return true + if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) return true + + const message = error instanceof Error + ? error.message.toLowerCase() + : typeof error === "string" + ? error.toLowerCase() + : "" + + return message.includes("timeout") + || message.includes("timed out") + || message.includes("failed to fetch") + || message.includes("networkerror") + || message.includes("network error") + || message.includes("gateway timeout") + || message.includes("econnreset") + || message.includes("socket hang up") +} + // Wait briefly for the pipeline to re-establish connection before failing a // send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network // blip) otherwise surface as a hard "Connection lost" toast even though the @@ -722,6 +759,20 @@ export async function optimisticSend(input: { try { await input.send(messageID) } catch (error) { + const acceptedRecords = isAmbiguousSendFailure(error) + ? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory) + : null + + if (acceptedRecords) { + materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords) + _optimisticConfirm?.({ + sessionID: input.sessionId, + directory: targetDirectory, + messageID, + }) + return + } + // Rollback via optimistic infrastructure _optimisticRemove({ sessionID: input.sessionId, @@ -739,6 +790,60 @@ export async function optimisticSend(input: { } } +async function fetchRecentSendConfirmationRecords( + sessionId: string, + messageID: string, + directory?: string | null, +): Promise | null> { + for (let attempt = 0; attempt < SEND_CONFIRMATION_REFETCH_ATTEMPTS; attempt += 1) { + if (attempt > 0) await wait(SEND_CONFIRMATION_REFETCH_RETRY_MS) + try { + const result = await sdk().session.messages({ + sessionID: sessionId, + directory: directory ?? undefined, + limit: SEND_CONFIRMATION_REFETCH_LIMIT, + }) + const records = (assertSdkSuccess(result, "session.messages") ?? []) + .filter((record: { info?: { id?: string } }) => !!record?.info?.id) as Array<{ info: Message; parts?: Part[] }> + if (records.some((record) => record.info.id === messageID)) { + return records + } + } catch { + // Confirmation is best-effort; if it fails, keep the original send error path. + } + } + return null +} + +function materializeConfirmedSendRecords( + store: DirectoryStoreApi, + sessionId: string, + messageID: string, + records: Array<{ info: Message; parts?: Part[] }>, +): void { + store.setState((state) => { + const currentMessages = state.message[sessionId] + const message = { ...state.message } + const part = { ...state.part } + if (currentMessages) { + const nextMessages = currentMessages.filter((message) => message.id !== messageID) + message[sessionId] = nextMessages + } + delete part[messageID] + + const materialized = materializeSessionSnapshots( + { ...state, message, part }, + sessionId, + records.map((record) => ({ + info: stripMessageDiffSnapshots(record.info), + parts: record.parts ?? [], + })), + { skipPartTypes: MESSAGE_REFETCH_SKIP_PARTS }, + ) + return { message: materialized.message, part: materialized.part } + }) +} + // --------------------------------------------------------------------------- // Abort // --------------------------------------------------------------------------- diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index 66d3f788..9c1171da 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -611,6 +611,13 @@ export function useSync() { [clearOptimistic, getOptimisticStore], ) + const optimisticConfirm = useCallback( + (input: { sessionID: string; directory?: string | null; messageID: string }) => { + clearOptimistic(input.sessionID, input.messageID, input.directory) + }, + [clearOptimistic], + ) + return useMemo( () => ({ ensureSessionRenderable: syncSession, @@ -622,8 +629,9 @@ export function useSync() { optimistic: { add: optimisticAdd, remove: optimisticRemove, + confirm: optimisticConfirm, }, }), - [syncSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove], + [syncSession, loadMore, hasMore, isLoading, isComplete, optimisticAdd, optimisticRemove, optimisticConfirm], ) } diff --git a/packages/web/server/index.js b/packages/web/server/index.js index fea5d94b..a3701b24 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -602,6 +602,7 @@ Object.defineProperties(openCodeNetworkState, { const openCodeNetworkRuntime = createOpenCodeNetworkRuntime({ state: openCodeNetworkState, getOpenCodeAuthHeaders, + configuredOpenCodeHostname: ENV_CONFIGURED_OPENCODE_HOSTNAME, }); const waitForReady = (...args) => openCodeNetworkRuntime.waitForReady(...args); diff --git a/packages/web/server/lib/opencode/network-runtime.js b/packages/web/server/lib/opencode/network-runtime.js index a9094e46..8214fb95 100644 --- a/packages/web/server/lib/opencode/network-runtime.js +++ b/packages/web/server/lib/opencode/network-runtime.js @@ -2,8 +2,21 @@ export const createOpenCodeNetworkRuntime = (deps) => { const { state, getOpenCodeAuthHeaders, + configuredOpenCodeHostname = '127.0.0.1', } = deps; + const resolveConnectHostname = () => { + const raw = typeof configuredOpenCodeHostname === 'string' ? configuredOpenCodeHostname.trim() : ''; + const hostname = raw || '127.0.0.1'; + if (hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]') { + return '127.0.0.1'; + } + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return hostname; + } + return hostname.includes(':') ? `[${hostname}]` : hostname; + }; + const normalizeApiPrefix = (prefix) => { if (!prefix) { return ''; @@ -77,7 +90,7 @@ export const createOpenCodeNetworkRuntime = (deps) => { const normalizedPath = path.startsWith('/') ? path : `/${path}`; const prefix = normalizeApiPrefix(prefixOverride !== undefined ? prefixOverride : ''); const fullPath = `${prefix}${normalizedPath}`; - const base = state.openCodeBaseUrl ?? `http://localhost:${state.openCodePort}`; + const base = state.openCodeBaseUrl ?? `http://${resolveConnectHostname()}:${state.openCodePort}`; return `${base}${fullPath}`; }; diff --git a/packages/web/server/lib/opencode/network-runtime.test.js b/packages/web/server/lib/opencode/network-runtime.test.js index 0423f145..b7f82f74 100644 --- a/packages/web/server/lib/opencode/network-runtime.test.js +++ b/packages/web/server/lib/opencode/network-runtime.test.js @@ -2,36 +2,56 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createOpenCodeNetworkRuntime } from './network-runtime.js'; -const createRuntime = () => createOpenCodeNetworkRuntime({ +const originalFetch = globalThis.fetch; + +const createRuntime = (overrides = {}) => createOpenCodeNetworkRuntime({ state: { openCodePort: 4096, openCodeBaseUrl: null, openCodeApiPrefix: '', openCodeApiPrefixDetected: false, openCodeApiDetectionTimer: null, + ...overrides.state, }, getOpenCodeAuthHeaders: () => ({}), + configuredOpenCodeHostname: overrides.configuredOpenCodeHostname, }); describe('OpenCode network runtime', () => { afterEach(() => { vi.useRealTimers(); - vi.unstubAllGlobals(); + globalThis.fetch = originalFetch; }); - it('clears the probe abort timer when readiness fetch rejects', async () => { - vi.useFakeTimers(); - vi.setSystemTime(0); - vi.stubGlobal('fetch', vi.fn(async () => { + it('returns false when readiness fetch rejects', async () => { + globalThis.fetch = vi.fn(async () => { throw new Error('offline'); - })); + }); const runtime = createRuntime(); const readyPromise = runtime.waitForReady('http://127.0.0.1:4096', 1); - await vi.advanceTimersByTimeAsync(100); await expect(readyPromise).resolves.toBe(false); + }); - expect(vi.getTimerCount()).toBe(0); + it('builds managed OpenCode URLs against IPv4 loopback by default', () => { + const runtime = createRuntime(); + + expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://127.0.0.1:4096/provider'); + }); + + it('keeps external OpenCode base URLs authoritative', () => { + const runtime = createRuntime({ + state: { openCodeBaseUrl: 'http://remote.example:4096' }, + }); + + expect(runtime.buildOpenCodeUrl('/provider')).toBe('http://remote.example:4096/provider'); + }); + + it('normalizes wildcard and IPv6 OpenCode bind hosts for local connects', () => { + expect(createRuntime({ configuredOpenCodeHostname: '0.0.0.0' }).buildOpenCodeUrl('/provider')) + .toBe('http://127.0.0.1:4096/provider'); + expect(createRuntime({ configuredOpenCodeHostname: '::1' }).buildOpenCodeUrl('/provider')) + .toBe('http://[::1]:4096/provider'); }); }); diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 3351a5f6..822a4bf8 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -181,6 +181,7 @@ export const registerOpenCodeProxy = (app, deps) => { os, path, OPEN_CODE_READY_GRACE_MS, + LONG_REQUEST_TIMEOUT_MS, getRuntime, getOpenCodeAuthHeaders, buildOpenCodeUrl, @@ -291,13 +292,48 @@ export const registerOpenCodeProxy = (app, deps) => { return externalBase; } - if (runtimeState.openCodePort) { - return `http://localhost:${runtimeState.openCodePort}`; - } - return FALLBACK_PROXY_TARGET; }; + const normalizeProxyTimeout = (value) => { + return Number.isFinite(value) && value > 0 ? value : 4 * 60 * 1000; + }; + + const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS); + const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut'); + + const isProxyTimeoutError = (error) => { + const code = typeof error?.code === 'string' ? error.code : ''; + const message = typeof error?.message === 'string' ? error.message.toLowerCase() : ''; + return code === 'ETIMEDOUT' + || code === 'ESOCKETTIMEDOUT' + || message.includes('timeout') + || message.includes('timed out'); + }; + + const sendProxyErrorResponse = (res, statusCode) => { + if (!res || res.headersSent || res.writableEnded || typeof res.status !== 'function') { + return false; + } + res.status(statusCode).json({ error: statusCode === 504 ? 'OpenCode upstream timed out' : 'OpenCode service unavailable' }); + return true; + }; + + const applyProxyResponseDeadline = (req, res, next) => { + const timeout = setTimeout(() => { + req[PROXY_TIMEOUT_MARKER] = true; + if (sendProxyErrorResponse(res, 504)) { + res.once('finish', () => req.destroy?.()); + } + }, PROXY_REQUEST_TIMEOUT_MS); + timeout.unref?.(); + + const clear = () => clearTimeout(timeout); + res.once('finish', clear); + res.once('close', clear); + next(); + }; + const forwardSseRequest = async (req, res) => { const abortController = new AbortController(); const closeUpstream = () => abortController.abort(); @@ -665,6 +701,8 @@ export const registerOpenCodeProxy = (app, deps) => { target: resolveProxyTarget(), changeOrigin: true, pathRewrite: { '^/api': '' }, + timeout: PROXY_REQUEST_TIMEOUT_MS, + proxyTimeout: PROXY_REQUEST_TIMEOUT_MS, // Dynamic target — port can change after restart router: () => resolveProxyTarget(), on: { @@ -700,11 +738,13 @@ export const registerOpenCodeProxy = (app, deps) => { } } }, - error: (err, _req, res) => { + error: (err, req, res) => { console.error('[proxy] OpenCode proxy error:', err.message); - if (res && !res.headersSent && typeof res.status === 'function') { - res.status(503).json({ error: 'OpenCode service unavailable' }); + if (req?.[PROXY_TIMEOUT_MARKER]) { + return; } + const statusCode = isProxyTimeoutError(err) ? 504 : 503; + sendProxyErrorResponse(res, statusCode); }, }, }); @@ -724,5 +764,6 @@ export const registerOpenCodeProxy = (app, deps) => { next(); }); + app.use('/api', applyProxyResponseDeadline); app.use('/api', apiProxy); }; diff --git a/packages/web/server/opencode-proxy.test.js b/packages/web/server/opencode-proxy.test.js index da4259b5..1abd4334 100644 --- a/packages/web/server/opencode-proxy.test.js +++ b/packages/web/server/opencode-proxy.test.js @@ -536,4 +536,42 @@ describe('OpenCode proxy SSE forwarding', () => { expect(data.body).toEqual(payload); expect(Number(data.contentLength)).toBeGreaterThan(0); }); + + it('uses the long proxy timeout budget for slow upstream responses', async () => { + const upstream = express(); + upstream.get('/slow', (_req, _res) => { + // Leave the response open so the proxy timeout path is exercised. + }); + upstreamServer = await listen(upstream); + const upstreamPort = upstreamServer.address().port; + const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`; + + const app = express(); + registerOpenCodeProxy(app, { + fs: {}, + os: {}, + path, + OPEN_CODE_READY_GRACE_MS: 0, + LONG_REQUEST_TIMEOUT_MS: 50, + getRuntime: () => ({ + openCodePort: upstreamPort, + openCodeBaseUrl: externalBaseUrl, + isOpenCodeReady: true, + openCodeNotReadySince: 0, + isRestartingOpenCode: false, + }), + getOpenCodeAuthHeaders: () => ({}), + buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`, + ensureOpenCodeApiPrefix: () => {}, + }); + proxyServer = await listen(app); + const proxyPort = proxyServer.address().port; + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/slow`, { + signal: AbortSignal.timeout(2000), + }); + + expect(response.status).toBe(504); + await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' }); + }); });