Skip to content

Commit a2d3877

Browse files
committed
Merge pull request #546 from ndycode/claude/audit-30-stream-stall-fix
fix(request): stream-stall failover, private-header prefix block, pinned-index message
2 parents 3a467cb + 3d08e35 commit a2d3877

5 files changed

Lines changed: 52 additions & 26 deletions

File tree

lib/request/rate-limit-decision.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,14 @@ export function buildPinnedUnavailableErrorBody(
195195
? accountSkipReasons.get(normalizedPinnedIndex) ?? null
196196
: null;
197197
const reasonSuffix = skipReason ? ` (${skipReason})` : "";
198-
const displayIndex = (normalizedPinnedIndex ?? 0) + 1;
198+
// On the desync path the pin index is unknown (null); claiming "account 1"
199+
// there would contradict the machine-readable pinnedAccountIndex: null.
200+
const accountPhrase =
201+
normalizedPinnedIndex === null
202+
? "The pinned account"
203+
: `Pinned account ${normalizedPinnedIndex + 1}`;
199204
return {
200-
message: `Pinned account ${displayIndex} is currently unavailable${reasonSuffix}; run \`codex-multi-auth status\` for details, or \`codex-multi-auth unpin\` to allow rotation.`,
205+
message: `${accountPhrase} is currently unavailable${reasonSuffix}; run \`codex-multi-auth status\` for details, or \`codex-multi-auth unpin\` to allow rotation.`,
201206
code: "codex_pinned_account_unavailable",
202207
pinnedAccountIndex: normalizedPinnedIndex,
203208
reason: skipReason,

lib/request/stream-failover-runtime.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,11 @@ export const HOP_BY_HOP_HEADERS = new Set([
1313
"transfer-encoding",
1414
"upgrade",
1515
]);
16-
const PRIVATE_CLIENT_RESPONSE_HEADERS = new Set([
17-
"x-codex-multi-auth-account-index",
18-
"x-codex-multi-auth-account-label",
19-
"x-codex-multi-auth-account-email",
20-
"x-codex-multi-auth-account-id",
21-
]);
16+
// Any header under this prefix carries account-identifying rotation metadata
17+
// (index/label/email/id today) and must never reach the client; matching by
18+
// prefix means a future header added under it is blocked by default instead
19+
// of leaking until someone remembers to extend an allowlist.
20+
const PRIVATE_CLIENT_RESPONSE_HEADER_PREFIX = "x-codex-multi-auth-account-";
2221
const DECODED_UPSTREAM_RESPONSE_HEADERS = new Set([
2322
// Node fetch returns decoded bytes while preserving the upstream encoding header.
2423
"content-encoding",
@@ -29,7 +28,7 @@ export function responseHeadersForClient(upstreamHeaders: Headers): Record<strin
2928
for (const [key, value] of upstreamHeaders.entries()) {
3029
const normalizedKey = key.toLowerCase();
3130
if (HOP_BY_HOP_HEADERS.has(normalizedKey)) continue;
32-
if (PRIVATE_CLIENT_RESPONSE_HEADERS.has(normalizedKey)) continue;
31+
if (normalizedKey.startsWith(PRIVATE_CLIENT_RESPONSE_HEADER_PREFIX)) continue;
3332
if (DECODED_UPSTREAM_RESPONSE_HEADERS.has(normalizedKey)) continue;
3433
headers[key] = value;
3534
}
@@ -48,8 +47,12 @@ export async function withTimeout<T>(
4847
promise,
4948
new Promise<T>((_resolve, reject) => {
5049
timeout = setTimeout(() => {
51-
onTimeout();
50+
// Reject BEFORE onTimeout: onTimeout side effects (e.g. cancelling a
51+
// stream reader) can settle `promise` with a clean value, and a
52+
// settlement enqueued ahead of this rejection would win the race —
53+
// turning a stall into a silent success.
5254
reject(new Error(message));
55+
onTimeout();
5356
}, Math.max(1, timeoutMs));
5457
}),
5558
]);

test/issue-474-pin-honored.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -719,7 +719,10 @@ describe("issue #474 — manual pin honored by runtime proxy", () => {
719719
);
720720
expect(body.pinnedAccountIndex).toBeNull();
721721
expect(body.reason).toBeNull();
722-
expect(body.message).toContain("Pinned account 1");
722+
// The desync path must not fabricate an index that contradicts the
723+
// machine-readable pinnedAccountIndex: null.
724+
expect(body.message).toContain("The pinned account is currently unavailable;");
725+
expect(body.message).not.toContain("Pinned account 1");
723726
});
724727

725728
it("mirrors the full accountSkipReasons map even when the pinned entry is unknown", () => {

test/rate-limit-decision.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,14 @@ describe("buildPinnedUnavailableErrorBody", () => {
275275
expect(body.account_skip_reasons).toEqual({ "0": "rate-limited", "1": "disabled" });
276276
});
277277

278-
it("omits the parenthetical and reason on the null-index desync path", () => {
278+
it("omits the index, parenthetical, and reason on the null-index desync path", () => {
279+
// Regression: the message used to render the null index as "Pinned
280+
// account 1", contradicting the machine-readable pinnedAccountIndex.
279281
const body = buildPinnedUnavailableErrorBody(null, new Map());
280282
expect(body.pinnedAccountIndex).toBeNull();
281283
expect(body.reason).toBeNull();
282-
expect(body.message).toContain("Pinned account 1 is currently unavailable;");
284+
expect(body.message).toContain("The pinned account is currently unavailable;");
285+
expect(body.message).not.toContain("Pinned account 1");
283286
expect(body.message).not.toContain("(");
284287
expect(body.account_skip_reasons).toEqual({});
285288
});

test/stream-failover-runtime.test.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,19 @@ describe("responseHeadersForClient", () => {
9696
});
9797
});
9898

99+
it("blocks any header under the private account prefix, not just known names", () => {
100+
// Regression: the filter used to be an exact-name allowlist, so a future
101+
// x-codex-multi-auth-account-* header would have leaked by default.
102+
const upstream = new Headers({
103+
"content-type": "application/json",
104+
"x-codex-multi-auth-account-plan": "pro",
105+
"X-Codex-Multi-Auth-Account-Future-Field": "secret",
106+
});
107+
expect(responseHeadersForClient(upstream)).toEqual({
108+
"content-type": "application/json",
109+
});
110+
});
111+
99112
it("covers every hop-by-hop header in the exported set", () => {
100113
const upstream = new Headers();
101114
for (const name of HOP_BY_HOP_HEADERS) {
@@ -232,14 +245,12 @@ describe("forwardStreamingResponse", () => {
232245
expect(res.chunks).toEqual([]);
233246
});
234247

235-
it("currently ends a stalled stream cleanly instead of failing it", async () => {
236-
// Pins a BUG (not the intended design): on a stall, withTimeout's
237-
// onTimeout cancels the reader, which settles the pending read() with
238-
// {done: true} BEFORE the rejection lands, so Promise.race resolves.
239-
// The stall is reported as a clean end: truncated body, res.end(), no
240-
// lastError, and onStreamError (the failover hook) never fires. The
241-
// stall branch of the catch block is unreachable today. Fixed in the
242-
// stacked follow-up PR, which flips these expectations.
248+
it("records the stall error, destroys the response, and reports failure on timeout", async () => {
249+
// Regression: withTimeout used to invoke onTimeout (which cancels the
250+
// reader and thereby settles the pending read() with {done: true})
251+
// before rejecting, so the race resolved and a stalled upstream was
252+
// forwarded as a clean end-of-stream — truncated body, res.end(), no
253+
// lastError, and onStreamError (the failover hook) never fired.
243254
const res = new FakeServerResponse();
244255
const status = createStatus();
245256
const upstream = new Response(streamOf(
@@ -250,13 +261,14 @@ describe("forwardStreamingResponse", () => {
250261
const onStreamError = vi.fn();
251262
await expect(
252263
forwardStreamingResponse(upstream, res.asServerResponse(), status, onStreamError, 25),
253-
).resolves.toBe(true);
264+
).resolves.toBe(false);
254265

255-
expect(onStreamError).not.toHaveBeenCalled();
256-
expect(status.lastError).toBeNull();
257-
expect(res.destroyed).toBe(false);
266+
expect(onStreamError).toHaveBeenCalledTimes(1);
267+
expect(status.lastError).toBe("upstream stream stalled after 25ms");
268+
expect(res.destroyed).toBe(true);
269+
expect(res.destroyError).toBeInstanceOf(Error);
258270
expect(Buffer.concat(res.chunks).toString("utf8")).toBe("data: a\n\n");
259-
expect(res.ended).toBe(true);
271+
expect(res.ended).toBe(false);
260272
});
261273

262274
it("fails the stream when the upstream read rejects mid-stream", async () => {

0 commit comments

Comments
 (0)