-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(responses): gate post-header reset recovery on SSE protocol state #4989
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -108,6 +108,13 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean { | |
| if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true; | ||
| const type = (payload as { type?: unknown }).type; | ||
| if (typeof type !== "string") return true; | ||
| if (type === "response.created") { | ||
| const response = (payload as { response?: unknown }).response; | ||
| if (response && typeof response === "object" && !Array.isArray(response)) { | ||
| const output = (response as { output?: unknown }).output; | ||
| if (Array.isArray(output) && output.length > 0) return true; | ||
| } | ||
| } | ||
| return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type); | ||
| } | ||
|
|
||
|
|
@@ -186,11 +193,13 @@ function failedTerminalResponse( | |
|
|
||
| export type ComboStreamPreflightResult = | ||
| | { kind: "accepted"; response: Response } | ||
| | { kind: "failed"; response: Response }; | ||
| | { kind: "failed"; response: Response } | ||
| | { kind: "read-error-before-output"; response: Response; error: unknown }; | ||
|
|
||
| /** | ||
| * Buffer a combo child's downstream SSE only until the request becomes unsafe to | ||
| * replay or reaches a terminal. This owns exactly one body reader. The aggregate | ||
| * Buffer a Responses SSE only until the request becomes unsafe to replay or reaches | ||
| * a terminal. Combo failover and native reset recovery share this protocol boundary. | ||
| * This owns exactly one body reader. The aggregate | ||
| * buffer is capped by bytes and retained chunks; hitting either cap commits the | ||
| * current target instead of growing memory or guessing that replay is safe. | ||
| */ | ||
|
|
@@ -211,12 +220,16 @@ export async function preflightComboStreamResponse( | |
| const buffered: Uint8Array[] = []; | ||
| let bufferedBytes = 0; | ||
| let outputCommitted = false; | ||
| let responseCreated = false; | ||
| let terminalStatus: ResponsesTerminalStatus | undefined; | ||
| let retryableTerminalPayload: Record<string, unknown> | undefined; | ||
| const inspector = createSseInspector({ | ||
| logCtx, | ||
| onOpaquePayload: () => { outputCommitted = true; }, | ||
| onParsedPayload: payload => { | ||
| if (terminalStatus !== undefined || outputCommitted || retryableTerminalPayload) return; | ||
| if (payload !== null && typeof payload === "object" && !Array.isArray(payload) | ||
| && (payload as { type?: unknown }).type === "response.created") responseCreated = true; | ||
| const retryable = retryableTerminal(payload); | ||
| const matchedBareError = retryable && payload !== null && typeof payload === "object" | ||
| && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error"; | ||
|
|
@@ -239,7 +252,10 @@ export async function preflightComboStreamResponse( | |
| // The native relay still owns post-header transport failures. Preserve | ||
| // the bounded prefix and the errored reader; cancelling it here would | ||
| // erase the failure before either client relay or inspection sees it. | ||
| return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) }; | ||
| const replay = replayBufferedResponse(response, reader, buffered); | ||
| return responseCreated && !outputCommitted && terminalStatus === undefined | ||
| ? { kind: "read-error-before-output", response: replay, error } | ||
| : { kind: "accepted", response: replay }; | ||
|
Comment on lines
+255
to
+258
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Run the required validation before marking the change review-ready. This multi-file stream-recovery change affects bun test tests/routing/combo-stream-preflight.test.ts
bun test tests/lib/upstream-retry.test.ts
bun run typecheck
bun run test:changed🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| } | ||
| if (next.done) { | ||
| inspector.finish(); | ||
|
|
@@ -277,3 +293,96 @@ export async function preflightComboStreamResponse( | |
| inspector.dispose(); | ||
| } | ||
| } | ||
|
|
||
| export type ProtocolSafeResetRecovery = (error: unknown) => Promise<Response | null>; | ||
|
|
||
| /** | ||
| * Defer protocol inspection until the downstream actually pulls the body. Direct | ||
| * passthrough must return response headers before the first SSE event arrives; | ||
| * combo routing is the only caller that intentionally awaits this preflight. | ||
| */ | ||
| export function deferProtocolSafeResetRecovery( | ||
| response: Response, | ||
| logCtx: RequestLogContext, | ||
| recover: ProtocolSafeResetRecovery, | ||
| options?: { allowMissingContentType?: boolean }, | ||
| ): Response { | ||
| if (!response.body) return response; | ||
|
|
||
| let reader: ReadableStreamDefaultReader<Uint8Array> | undefined; | ||
| let initialization: Promise<void> | undefined; | ||
| let closed = false; | ||
|
|
||
| const cancelBody = (body: ReadableStream<Uint8Array> | null, reason?: unknown): void => { | ||
| try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ } | ||
| }; | ||
| const initialize = async (): Promise<void> => { | ||
| const preflight = await preflightComboStreamResponse( | ||
| response, | ||
| logCtx, | ||
| () => false, | ||
| { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true }, | ||
| ); | ||
| let selected = preflight.response; | ||
| if (preflight.kind === "read-error-before-output") { | ||
| const replacement = await recover(preflight.error); | ||
| if (replacement) { | ||
| cancelBody(selected.body, "using protocol-safe replacement stream"); | ||
| selected = replacement; | ||
| } | ||
| } | ||
| if (closed) { | ||
| cancelBody(selected.body, "downstream cancelled before protocol preflight completed"); | ||
| return; | ||
| } | ||
| reader = selected.body?.getReader(); | ||
| }; | ||
|
|
||
| const body = new ReadableStream<Uint8Array>({ | ||
| async pull(controller) { | ||
| try { | ||
| initialization ??= initialize(); | ||
| await initialization; | ||
| if (closed) return; | ||
| if (!reader) { | ||
| closed = true; | ||
| controller.close(); | ||
| return; | ||
| } | ||
| const next = await reader.read(); | ||
| if (closed) return; | ||
| if (next.done) { | ||
| closed = true; | ||
| try { reader.releaseLock(); } catch { /* already released */ } | ||
| reader = undefined; | ||
| controller.close(); | ||
| return; | ||
| } | ||
| controller.enqueue(next.value); | ||
| } catch (error) { | ||
| if (closed) return; | ||
| closed = true; | ||
| try { reader?.releaseLock(); } catch { /* errored reader */ } | ||
| reader = undefined; | ||
| controller.error(error); | ||
| } | ||
| }, | ||
| cancel(reason) { | ||
| if (closed) return; | ||
| closed = true; | ||
| if (reader) { | ||
| try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ } | ||
| try { reader.releaseLock(); } catch { /* already released */ } | ||
| reader = undefined; | ||
| } else { | ||
| cancelBody(response.body, reason); | ||
| } | ||
|
Comment on lines
+377
to
+379
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: sed -n '190,400p' src/server/responses/combo-stream-preflight.ts
rg -n -A20 -B10 'function cancelBody|const cancelBody|cancelBody\(' src/server/responses src/serverRepository: lidge-jun/opencodex Length of output: 19916 Cancel the active preflight reader during initialization. When downstream cancellation occurs before If the upstream has emitted 🤖 Prompt for AI Agents |
||
| }, | ||
| }, { highWaterMark: 0 }); | ||
|
|
||
| return new Response(body, { | ||
| status: response.status, | ||
| statusText: response.statusText, | ||
| headers: response.headers, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -112,7 +112,9 @@ import { | |
| fetchWithTransientRetry, | ||
| applyUpstreamRecoveryInit, | ||
| isNonReplayableResponse, | ||
| prepareSameTarget429Wait, | ||
| refetchAfterProtocolSafeReset, | ||
| TRANSIENT_RETRY_MAX_ATTEMPTS, | ||
| prepareSameTarget429Wait, | ||
| sleepWithAbort, | ||
| } from "../../lib/upstream-retry"; | ||
| import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error"; | ||
|
|
@@ -149,7 +151,8 @@ import { | |
| reasoningEffortRejectionText, | ||
| } from "./core-opaque-recovery"; | ||
| import type { RequestLogContext } from "../request-log"; | ||
| import { preflightComboStreamResponse } from "./combo-stream-preflight"; | ||
| import { deferProtocolSafeResetRecovery, preflightComboStreamResponse } from "./combo-stream-preflight"; | ||
| import { isCodexWsUpstreamResponse } from "./ws-upstream"; | ||
| import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION } from "../../lib/errors"; | ||
| import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit"; | ||
| import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata"; | ||
|
|
@@ -179,6 +182,8 @@ export async function preparePassthroughExchange( | |
| | "genericFailoverAccountId" | ||
| | "passiveQuotaWriterGeneration" | ||
| | "oauthDispatch" | ||
| | "selectionIsCurrent" | ||
| | "requestBindings" | ||
| | "resolveSelectionAdapter" | ||
| | "isOAuth401ReplayProvider" | ||
| | "sentOAuthSnapshot" | ||
|
|
@@ -1544,6 +1549,79 @@ export async function preparePassthroughExchange( | |
| continue passthroughRecovery; | ||
| } | ||
| } | ||
|
|
||
| const streamRecoveryContentType = upstreamResponse.headers.get("content-type")?.toLowerCase() ?? ""; | ||
| const protocolRecoveryCandidate = upstreamResponse.ok | ||
| && !!upstreamResponse.body | ||
| && !isNonReplayableResponse(upstreamResponse) | ||
| && !isCodexWsUpstreamResponse(upstreamResponse) | ||
| // A downstream WebSocket turn that fell back to HTTP must relay response.created | ||
| // immediately so the client can address the turn and receive explicit control | ||
| // refusal. The recovery preflight must retain that event until output commits, so | ||
| // the two contracts cannot share one body owner. | ||
| && !(options.nativeControl && options.inboundTransport === "websocket") | ||
| && remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS) > 0 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a key-auth AGENTS.md reference: src/AGENTS.md:L10-L11 Useful? React with 👍 / 👎.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Honor the provider-resolved transient send cap. If a provider config sets its transient retry total to one send, the initial send consumes that allowance. Use Proposed fix- && remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS) > 0
+ && remainingTransientSendBudget(transientSendAttempts()) > 0
...
- attempts: Math.min(1, remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS)),
+ attempts: Math.min(1, remainingTransientSendBudget(transientSendAttempts())),🤖 Prompt for AI Agents |
||
| && (streamRecoveryContentType.includes("text/event-stream") || (!streamRecoveryContentType && parsed.stream)); | ||
| if (protocolRecoveryCandidate) { | ||
| upstreamResponse = deferProtocolSafeResetRecovery( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- file map ---'
ast-grep outline src/server/responses/passthrough-dispatch.ts
printf '%s\n' '--- focused source ---'
sed -n '1450,1665p' src/server/responses/passthrough-dispatch.ts
printf '%s\n' '--- related symbols ---'
rg -n -C 5 'deferProtocolSafeResetRecovery|preflightComboStreamResponse|initialize\(|response\.body|reader|AbortController|abort\(' src/server/responses/passthrough-dispatch.tsRepository: lidge-jun/opencodex Length of output: 19665 🤖 get_repo_knowledge executed:
Length of output: 12653 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- helper location ---'
fd -t f -i 'combo-stream-preflight' .
printf '%s\n' '--- helper outline ---'
ast-grep outline src/server/responses/combo-stream-preflight.ts
printf '%s\n' '--- helper source ---'
sed -n '1,280p' src/server/responses/combo-stream-preflight.ts
printf '%s\n' '--- direct references ---'
rg -n -C 8 'deferProtocolSafeResetRecovery|preflightComboStreamResponse|Preflight|deferred' src testsRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: #!/bin/bash
set -eu
wc -l src/server/responses/combo-stream-preflight.ts
sed -n '1,260p' src/server/responses/combo-stream-preflight.ts
printf '%s\n' '--- narrow references ---'
rg -n -C 6 'deferProtocolSafeResetRecovery|preflightComboStreamResponse' src/server/responses tests/responses --glob '*.ts' | head -n 400Repository: lidge-jun/opencodex Length of output: 21169 🏁 Script executed: #!/bin/bash
set -eu
cat -n src/server/responses/combo-stream-preflight.ts | sed -n '290,388p'Repository: lidge-jun/opencodex Length of output: 3916 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- signal helper and upstream setup ---'
rg -n -C 12 'function linkAbortSignal|const linkAbortSignal|linkAbortSignal\(' src/server/responses src/server | head -n 240
printf '%s\n' '--- recovery helper ---'
rg -n -C 18 'function refetchAfterProtocolSafeReset|const refetchAfterProtocolSafeReset|refetchAfterProtocolSafeReset' src/server/responses/passthrough-dispatch.ts src/server/responses --glob '*.ts' | head -n 260
printf '%s\n' '--- initial fetch call ---'
sed -n '1260,1325p' src/server/responses/passthrough-dispatch.tsRepository: lidge-jun/opencodex Length of output: 40094 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- retry helpers ---'
rg -n -C 12 'export async function (refetchAfterProtocolSafeReset|fetchWithTransientRetry)|function refetchAfterProtocolSafeReset|function fetchWithTransientRetry|refetchAfterProtocolSafeReset|fetchWithTransientRetry' src/lib/upstream-retry.ts | head -n 320
printf '%s\n' '--- deferred response delivery path ---'
rg -n -C 18 'preparePassthroughExchange|relayWithAbort|upstreamResponse' src/server/responses/passthrough-delivery.ts src/server/responses --glob '*.ts' | head -n 500Repository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- refetch completion ---'
sed -n '681,735p' src/lib/upstream-retry.ts
printf '%s\n' '--- relay cancellation ---'
rg -n -C 16 'export function relaySseWithFailedTail|function relaySseWithFailedTail|export function teeWithBoundedInspection|function teeWithBoundedInspection' src/server/relay.ts src/server/inspection-tee.ts src/server/relay-eager.ts
printf '%s\n' '--- helper cancel references ---'
rg -n -C 8 'upstream\.abort|onClientCancel|cancel\(reason\)' src/server/relay.ts src/server/inspection-tee.ts src/server/relay-eager.ts | head -n 300Repository: lidge-jun/opencodex Length of output: 18877 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- delivery inspection call ---'
sed -n '627,710p' src/server/responses/passthrough-delivery.ts
printf '%s\n' '--- inspection consumer definitions ---'
rg -n -C 18 'consumeForResponseLogMetadata|consumeForInspection|clientGoneSignal|upstream: AbortController' src/server/relay.ts src/server/responses/passthrough-delivery.ts | head -n 500Repository: lidge-jun/opencodex Length of output: 39330 Abort the deferred preflight reader on downstream cancellation. 🤖 Prompt for AI Agents |
||
| upstreamResponse, | ||
| { model: logCtx.model, provider: logCtx.provider }, | ||
| error => refetchAfterProtocolSafeReset( | ||
| (_recovery, signal = upstream.signal) => fetchWithTransientRetry( | ||
| () => fetchWithHeaderTimeout( | ||
| request.url, | ||
| applyUpstreamRecoveryInit({ | ||
| method: request.method, | ||
| headers: request.headers, | ||
| body: request.body, | ||
| }, "connection-reset"), | ||
| signal, | ||
| connectMs, | ||
| true, | ||
| providerFetch(route.provider, options.codexWsRuntimeIdentity, { | ||
| httpOnly: true, | ||
| providerName: route.providerName, | ||
| modelId: route.modelId, | ||
| dispatchOverride: oauthDispatch(request), | ||
| beforeDispatch: headers => { | ||
| if (signal.aborted) throw signal.reason; | ||
| if (!transportState.selectionIsCurrent(transportState.requestBindings.get(request))) { | ||
| throw new Error("Credential selection changed before pre-output stream recovery"); | ||
| } | ||
| if (isCanonicalOpenAiForwardProvider(route.provider)) { | ||
| createCodexReserveDispatchGuard( | ||
| admissionState.authCtx, | ||
| options.codexAuthPolicy ?? config, | ||
| route.modelId, | ||
| options.admission, | ||
| options.visionDescribeTerminal === true, | ||
| )?.(headers); | ||
| } | ||
| transportState.noteRoutedAttemptSend(passthroughEstimate, "connection-reset"); | ||
| }, | ||
| }), | ||
| route.provider.authMode === "forward", | ||
| ).then(adoptObservedResponse), | ||
| { | ||
| abortSignal: signal, | ||
| label: safeHostLabel(request.url), | ||
| attempts: Math.min(1, remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS)), | ||
| onSendsConsumed: noteTransientSends, | ||
| }, | ||
| ), | ||
| error, | ||
| { | ||
| abortSignal: upstream.signal, | ||
| label: safeHostLabel(request.url), | ||
| acceptResponse: candidate => { | ||
| const type = candidate.headers.get("content-type")?.toLowerCase() ?? ""; | ||
| return type.includes("text/event-stream") || (!type && parsed.stream); | ||
| }, | ||
| }, | ||
| ), | ||
| { allowMissingContentType: !streamRecoveryContentType && parsed.stream }, | ||
| ); | ||
| } | ||
| break; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 10322
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 18303
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 16111
Document the complete reset-recovery gate.
Both pages state that stream replacement is eligible after
response.createdwhen no output or tool event was observed. This leaves the documented connection-reset gate incomplete and can imply that replacement remains eligible after other payloads.preflightComboStreamResponsemarks opaque payloads as committed. This includes malformed JSON and[DONE].comboStreamPayloadCommitsOutputalso commits unknown event types.response.completed,response.failed, andresponse.incompleteare terminal events; the inspector setsterminalStatus, and the reset-recovery branch requires it to remain unset. Retryable terminal payloads use a separate failover result and are not eligible for this reset-replacement path.Update both
docs-site/src/content/docs/reference/configuration/server.md:587-593anddocs-site/src/content/docs/ko/reference/configuration/server.md:241-247to state that reset replacement requiresresponse.createdfollowed by no output, tool, unknown, opaque or malformed, completion, or other terminal payload. The Korean page must preserve the same boundary because the English page is canonical.🤖 Prompt for AI Agents