Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/lib/response-body-inactivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { idleDeadline } from "./abort";

/**
* Bound response-body silence after fetch has returned its headers. The deadline is armed only
* while an upstream read is pending, so a downstream client applying backpressure does not count
* as an upstream stall. Non-empty chunks reset the window; normal completion leaves the upstream
* controller untouched.
*/
export function guardResponseBodyInactivity(
response: Response,
upstream: AbortController,
inactivityMs: number,
): Response {
if (!response.body || inactivityMs <= 0) return response;

const reader = response.body.getReader();
let settled = false;
let output: ReadableStreamDefaultController<Uint8Array> | undefined;
const timeoutError = new DOMException(
`Upstream response body stalled for ${inactivityMs}ms`,
"TimeoutError",
);
const idle = idleDeadline(inactivityMs, () => {
if (settled) return;
settled = true;
upstream.abort(timeoutError);
reader.cancel(timeoutError).catch(() => {});
try { output?.error(timeoutError); } catch { /* already torn down */ }
});

const body = new ReadableStream<Uint8Array>({
async pull(controller) {
output = controller;
try {
idle.reset();
for (;;) {
const { done, value } = await reader.read();
if (settled) return;
if (done) {
settled = true;
idle.cancel();
controller.close();
return;
}
if (value.byteLength === 0) continue;
idle.pause();
controller.enqueue(value);
return;
}
} catch (error) {
if (settled) return;
settled = true;
idle.cancel();
try { controller.error(error); } catch { /* already torn down */ }
}
},
cancel(reason) {
if (settled) return;
settled = true;
idle.cancel();
upstream.abort(reason);
reader.cancel(reason).catch(() => {});
},
});

return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
1 change: 1 addition & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { guardResponseBodyInactivity } from "../lib/response-body-inactivity";
import { markActivity } from "../lib/sidecar-tracker";
import {
buildWarmupCompletionFrames,
Expand Down
6 changes: 6 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ import type { WsData } from "../ws-bridge";
import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
import { redactSecretString } from "../../lib/redact";
import { readBoundedResponseBody } from "../../lib/bounded-body";
import { guardResponseBodyInactivity } from "../../lib/response-body-inactivity";
import type { AdmissionLease } from "../../lib/admission";
import { supportedLadderFor } from "../effort-policy";
import { isThreadSpawnRequest } from "../effort-policy";
Expand Down Expand Up @@ -1783,6 +1784,9 @@ async function handleResponsesInner(
const upstream = new AbortController();
linkAbortSignal(upstream, options.abortSignal);
const connectMs = config.connectTimeoutMs ?? 200_000;
const bodyInactivityMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0
? Math.floor(config.stallTimeoutSec * 1000)
: 300_000;
let upstreamResponse: Response;
const transportFailureResponse = (err: unknown): Response => {
upstream.abort();
Expand Down Expand Up @@ -1941,6 +1945,7 @@ async function handleResponsesInner(
}
}
}
upstreamResponse = guardResponseBodyInactivity(upstreamResponse, upstream, bodyInactivityMs);
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
const resolvedModel = headers.get("openai-model")?.trim();
if (resolvedModel) logCtx.resolvedModel = resolvedModel;
Expand Down Expand Up @@ -2957,6 +2962,7 @@ async function handleResponsesInner(
}
break;
}
upstreamResponse = guardResponseBodyInactivity(upstreamResponse, upstream, stallTimeoutMs);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard terminal-continuation response bodies.

Line 2965 guards only the initial adapter response. fetchTerminalGuardContinuation fetches another adapter response at line 3090. It parses that body at lines 3231-3235 without an inactivity guard.

If the continuation sends headers and then stalls, the parser remains pending and the upstream turn remains active. Wrap the continuation response before checking its status or parsing its body. Add a regression test for a headers-only continuation response.

Proposed fix
-        response = await fetchContinuation(recoveryKind);
+        response = guardResponseBodyInactivity(
+          await fetchContinuation(recoveryKind),
+          upstream,
+          stallTimeoutMs,
+        );

As per path instructions, flag provider/adapter contract drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/responses/core.ts` at line 2965, Update
fetchTerminalGuardContinuation to pass the continuation adapter response through
guardResponseBodyInactivity before checking its status or parsing the body,
matching the initial upstreamResponse handling. Add a regression test covering a
headers-only continuation that stalls, and flag any provider/adapter contract
drift revealed by the fix.

Source: Path instructions

if (!upstreamResponse.ok) {
if (options.comboAttempt) {
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
Expand Down
42 changes: 42 additions & 0 deletions tests/response-body-inactivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import { guardResponseBodyInactivity } from "../src/lib/response-body-inactivity";

describe("upstream response body inactivity", () => {
test("guards both passthrough and adapter response bodies after headers", async () => {
const core = await Bun.file(new URL("../src/server/responses/core.ts", import.meta.url)).text();
expect(core).toContain("guardResponseBodyInactivity(upstreamResponse, upstream, bodyInactivityMs)");
expect(core).toContain("guardResponseBodyInactivity(upstreamResponse, upstream, stallTimeoutMs)");
});

test("aborts a headers-only upstream", async () => {
const upstream = new AbortController();
const response = guardResponseBodyInactivity(new Response(
new ReadableStream<Uint8Array>({ pull() { return new Promise<void>(() => {}); } }),
), upstream, 20);

await expect(response.text()).rejects.toMatchObject({ name: "TimeoutError" });
expect(upstream.signal.aborted).toBe(true);
expect(upstream.signal.reason).toMatchObject({ name: "TimeoutError" });
});

test("resets on bytes and ignores downstream backpressure", async () => {
const encoder = new TextEncoder();
const upstream = new AbortController();
let controller!: ReadableStreamDefaultController<Uint8Array>;
const guarded = guardResponseBodyInactivity(new Response(new ReadableStream({
start(value) { controller = value; },
})), upstream, 30);
const reader = guarded.body!.getReader();

controller.enqueue(encoder.encode("one"));
controller.enqueue(encoder.encode("two"));
expect(new TextDecoder().decode((await reader.read()).value)).toBe("one");
await Bun.sleep(45);
expect(upstream.signal.aborted).toBe(false);

expect(new TextDecoder().decode((await reader.read()).value)).toBe("two");
controller.close();
expect((await reader.read()).done).toBe(true);
expect(upstream.signal.aborted).toBe(false);
});
});
Loading