-
Notifications
You must be signed in to change notification settings - Fork 1.1k
fix(logs): bound response inspection without losing SSE finality #4775
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
Draft
luvs01
wants to merge
6
commits into
lidge-jun:dev
Choose a base branch
from
luvs01:codex/pr177-bounded-response-inspection-20260916
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
cf57bc0
fix(logs): bound response inspection without losing SSE finality
luvs01 7adf8c6
Merge upstream dev b3035fe into bounded response inspection
luvs01 1bea8a9
Merge upstream dev 5e3029e6 into bounded response inspection
luvs01 149f0a9
test(logs): pin the bounded inspection tee and document the guide entry
luvs01 f9a3882
Merge upstream dev b6d9d0c3
luvs01 7257078
Merge upstream dev ada3a9b1
luvs01 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| --- | ||
| title: Response inspection and large responses | ||
| description: How bounded diagnostic retention and streaming inspection interact with response delivery. | ||
| --- | ||
|
|
||
| OpenCodex keeps response diagnostics bounded without making the logging limit a | ||
| limit on the bytes delivered to your client. Other provider, request and transport | ||
| limits still apply independently. | ||
|
|
||
| ## JSON and ordinary error responses | ||
|
|
||
| JSON inspection retains at most 32 MiB of source bytes. If the body exceeds that | ||
| allowance, logging drops its retained copy and continues forwarding the original | ||
| response. It does not parse a truncated prefix as authoritative usage or model | ||
| metadata. Usage already supplied by another trusted path is preserved; missing | ||
| usage is not replaced with an invented zero. Ordinary non-JSON error diagnostics | ||
| retain only the first 8 KiB and pass through the existing redaction logic. | ||
|
|
||
| The client receives chunks as it reads them rather than waiting for diagnostic | ||
| inspection of the whole body. A read failure is recorded as 502 and cancellation | ||
| as 499 in request history; these diagnostic outcomes do not rewrite HTTP headers | ||
| that have already been sent. Logging is finalized once. | ||
|
|
||
| ## Streaming responses | ||
|
|
||
| Native SSE inspection pauses when it runs too far ahead of client consumption. | ||
| The allowance is 32 MiB plus source-chunk/native-prefetch overhead, not a total | ||
| response-size limit or a cap on all process memory. A longer response is still | ||
| inspected through its actual completion event, including terminal usage and | ||
| continuation state. | ||
|
|
||
| After the client disconnects, the existing bounded drain can still observe a late | ||
| completion for up to 15 seconds or 32 MiB of additional inspection. A forced | ||
| shutdown is different: it discards uncompleted candidates rather than recording | ||
| them as a completed response. Existing transport selection and WebSocket memory | ||
| bounds are unchanged. No new configuration setting is required. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| /** Read-ahead allowance, not a lifetime limit on SSE inspection or delivery. */ | ||
| export const MAX_INSPECTION_READ_AHEAD_BYTES = 32 * 1024 * 1024; | ||
|
|
||
| export interface InspectionTeeOptions { | ||
| /** Release pacing so the inspection owner's existing bounded drain can run. */ | ||
| clientGoneSignal?: AbortSignal; | ||
| /** Internal test seam; callers must not take this value from upstream data. */ | ||
| maxReadAheadBytes?: number; | ||
| } | ||
|
|
||
| /** | ||
| * Keep native tee cancellation semantics while pacing the inspection branch. | ||
| * | ||
| * Inspection may lead raw client consumption by the allowance plus one source | ||
| * chunk (and native tee prefetch). It never stops merely because the whole turn | ||
| * crossed that size: terminal, usage and continuation observers retain ownership. | ||
| * Count raw client bytes BEFORE rewrites, which may shrink, drop or expand them. | ||
| * Push sources still need their own producer-side bound; this is not an RSS cap. | ||
| */ | ||
| export function teeWithBoundedInspection( | ||
| source: ReadableStream<Uint8Array>, | ||
| options: InspectionTeeOptions = {}, | ||
| ): [ReadableStream<Uint8Array>, ReadableStream<Uint8Array>] { | ||
| const limit = options.maxReadAheadBytes ?? MAX_INSPECTION_READ_AHEAD_BYTES; | ||
| if (!Number.isSafeInteger(limit) || limit <= 0) { | ||
| throw new RangeError("Inspection read-ahead limit must be a positive safe integer"); | ||
| } | ||
| const [client, inspection] = source.tee(); | ||
| let leadBytes = 0; | ||
| let pacingReleased = false; | ||
| let credit: Promise<void> | undefined; | ||
| let wake: (() => void) | undefined; | ||
|
|
||
| const wakeReader = () => { | ||
| const resolve = wake; | ||
| wake = undefined; | ||
| credit = undefined; | ||
| resolve?.(); | ||
| }; | ||
| const releasePacing = () => { | ||
| pacingReleased = true; | ||
| wakeReader(); | ||
| }; | ||
| const signal = options.clientGoneSignal; | ||
| if (signal?.aborted) releasePacing(); | ||
| else signal?.addEventListener("abort", releasePacing, { once: true }); | ||
|
|
||
| const wrap = ( | ||
| body: ReadableStream<Uint8Array>, | ||
| isClient: boolean, | ||
| ): ReadableStream<Uint8Array> => { | ||
| const reader = body.getReader(); | ||
| let ended = false; | ||
| // An upstream error must wake an inspector waiting for credit, not remain | ||
| // hidden until the client happens to issue another read. One observer per | ||
| // reader, not a permanent Promise.race reaction attached on every chunk. | ||
| void reader.closed.catch(releasePacing); | ||
| const releaseLock = () => { | ||
| try { reader.releaseLock(); } catch { /* a pending read owns the lock */ } | ||
| }; | ||
| const finish = () => { | ||
| ended = true; | ||
| releasePacing(); | ||
| if (!isClient) signal?.removeEventListener("abort", releasePacing); | ||
| }; | ||
| return new ReadableStream<Uint8Array>({ | ||
| async pull(controller) { | ||
| if (ended) return; | ||
| if (!isClient && !pacingReleased && leadBytes >= limit) { | ||
| credit ??= new Promise<void>(resolve => { wake = resolve; }); | ||
| await credit; | ||
| if (ended) return; | ||
| } | ||
| try { | ||
| const result = await reader.read(); | ||
| if (ended) return; | ||
| if (result.done) { | ||
| finish(); | ||
| releaseLock(); | ||
| controller.close(); | ||
| return; | ||
| } | ||
| if (isClient) { | ||
| leadBytes -= result.value.byteLength; | ||
| if (leadBytes < limit) wakeReader(); | ||
| } else { | ||
| leadBytes += result.value.byteLength; | ||
| } | ||
| controller.enqueue(result.value); | ||
| } catch (error) { | ||
| if (ended) return; | ||
| finish(); | ||
| releaseLock(); | ||
| controller.error(error); | ||
| } | ||
| }, | ||
| cancel(reason) { | ||
| finish(); | ||
| // Awaiting one tee branch's cancellation waits for the sibling. The | ||
| // owner must be free to finish cleanup and cancel/read that sibling. | ||
| void reader.cancel(reason).catch(() => undefined); | ||
| releaseLock(); | ||
| }, | ||
| }, { highWaterMark: 0 }); | ||
| }; | ||
| return [wrap(client, true), wrap(inspection, false)]; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| /** Bounds diagnostic retention, never the bytes delivered to the caller. */ | ||
| export const MAX_RESPONSE_LOG_INSPECTION_BYTES = 32 * 1024 * 1024; | ||
| export const MAX_NON_JSON_ERROR_INSPECTION_BYTES = 8 * 1024; | ||
| const INSPECTION_BLOCK_BYTES = 64 * 1024; | ||
|
|
||
| export type ResponseLogBodyEnd = "eof" | "read_error" | "cancel"; | ||
|
|
||
| export interface ResponseLogBodyOptions { | ||
| json: boolean; | ||
| inspect: (text: string) => void; | ||
| finalize: (reason: ResponseLogBodyEnd) => void; | ||
| /** Internal test seam; this is not a provider-controlled limit. */ | ||
| maxInspectionBytes?: number; | ||
| } | ||
|
|
||
| /** Fixed-size blocks bound both retained bytes and per-chunk bookkeeping. */ | ||
| class ResponseLogInspection { | ||
| private blocks: Uint8Array[] = []; | ||
| private bytes = 0; | ||
| private overflowed = false; | ||
|
|
||
| constructor(private readonly json: boolean, private readonly limit: number) {} | ||
|
|
||
| append(chunk: Uint8Array): void { | ||
| if (this.overflowed || chunk.byteLength === 0) return; | ||
| if (this.json && chunk.byteLength > this.limit - this.bytes) { | ||
| // A JSON prefix is not an authoritative response. Drop it immediately, | ||
| // without either truncating the delivery stream or retaining later bytes. | ||
| this.overflowed = true; | ||
| this.dispose(); | ||
| return; | ||
| } | ||
| let remaining = Math.min(chunk.byteLength, this.limit - this.bytes); | ||
| let offset = 0; | ||
| while (remaining > 0) { | ||
| const blockOffset = this.bytes % INSPECTION_BLOCK_BYTES; | ||
| if (blockOffset === 0) { | ||
| this.blocks.push(new Uint8Array(Math.min(INSPECTION_BLOCK_BYTES, this.limit - this.bytes))); | ||
| } | ||
| const block = this.blocks[this.blocks.length - 1]!; | ||
| const length = Math.min(remaining, block.byteLength - blockOffset); | ||
| block.set(chunk.subarray(offset, offset + length), blockOffset); | ||
| this.bytes += length; | ||
| offset += length; | ||
| remaining -= length; | ||
| } | ||
| } | ||
|
|
||
| text(reason: ResponseLogBodyEnd): string | undefined { | ||
| // Even a syntactically valid JSON prefix must not update usage/model | ||
| // metadata when the transport did not reach EOF. | ||
| if (this.json && (reason !== "eof" || this.overflowed)) return undefined; | ||
| const combined = new Uint8Array(this.bytes); | ||
| let offset = 0; | ||
| for (const block of this.blocks) { | ||
| const length = Math.min(block.byteLength, this.bytes - offset); | ||
| combined.set(block.subarray(0, length), offset); | ||
| offset += length; | ||
| } | ||
| return new TextDecoder().decode(combined); | ||
| } | ||
|
|
||
| dispose(): void { | ||
| this.blocks.length = 0; | ||
| this.bytes = 0; | ||
| } | ||
| } | ||
|
|
||
| /** Optional diagnostics must not change the response's transport outcome. */ | ||
| function bestEffort(callback: () => void): void { | ||
| try { | ||
| callback(); | ||
| } catch { | ||
| return; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Forward one upstream read per downstream pull, with bounded side inspection. | ||
| * EOF, read failure and cancellation each finalize at most once. Cancellation | ||
| * does not await the upstream cancel promise: one branch of a tee can otherwise | ||
| * wait for a sibling that the same caller intends to consume or cancel later. | ||
| */ | ||
| export function createBoundedResponseLogBody( | ||
| body: ReadableStream<Uint8Array>, | ||
| options: ResponseLogBodyOptions, | ||
| ): ReadableStream<Uint8Array> { | ||
| const limit = options.maxInspectionBytes ?? (options.json | ||
| ? MAX_RESPONSE_LOG_INSPECTION_BYTES | ||
| : MAX_NON_JSON_ERROR_INSPECTION_BYTES); | ||
| if (!Number.isSafeInteger(limit) || limit < 0) { | ||
| throw new RangeError("Response log inspection limit must be a non-negative safe integer"); | ||
| } | ||
| const inspection = new ResponseLogInspection(options.json, limit); | ||
| const reader = body.getReader(); | ||
| let ended = false; | ||
| let inspectionFailed = false; | ||
|
|
||
| const release = () => bestEffort(() => reader.releaseLock()); | ||
| const finish = (reason: ResponseLogBodyEnd) => { | ||
| if (ended) return; | ||
| ended = true; // Set before callbacks or a pending read resumes. | ||
| try { | ||
| if (!inspectionFailed) { | ||
| bestEffort(() => { | ||
| const text = inspection.text(reason); | ||
| if (text !== undefined) options.inspect(text); | ||
| }); | ||
| } | ||
| } finally { | ||
| inspection.dispose(); | ||
| bestEffort(() => options.finalize(reason)); | ||
| } | ||
| }; | ||
|
|
||
| return new ReadableStream<Uint8Array>({ | ||
| async pull(controller) { | ||
| if (ended) return; | ||
| let result: Awaited<ReturnType<typeof reader.read>>; | ||
| try { | ||
| result = await reader.read(); | ||
| } catch (error) { | ||
| if (ended) return; // Cancellation owns its pending-read settlement. | ||
| finish("read_error"); | ||
| release(); | ||
| controller.error(error); | ||
| return; | ||
| } | ||
| if (ended) return; | ||
| if (result.done) { | ||
| finish("eof"); | ||
| release(); | ||
| controller.close(); | ||
| return; | ||
| } | ||
| if (!inspectionFailed) { | ||
| try { | ||
| inspection.append(result.value); | ||
| } catch { | ||
| inspectionFailed = true; | ||
| inspection.dispose(); | ||
| } | ||
| } | ||
| // Do not decode/re-encode transport bytes, including malformed UTF-8. | ||
| controller.enqueue(result.value); | ||
| }, | ||
| cancel(reason) { | ||
| finish("cancel"); | ||
| bestEffort(() => { void reader.cancel(reason).catch(() => undefined); }); | ||
| release(); | ||
| }, | ||
| }, { highWaterMark: 0 }); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.