Skip to content

Commit a365d5c

Browse files
codeslakeclaude
andcommitted
fix: a fast upstream failure hung the client instead of answering 502
handleMessages installs an abort so a client that gives up mid-SSE frees the upstream. It was keyed on clientReq's "close" — which Node emits when the request BODY has been consumed, i.e. on every request, immediately — so it aborted while the client was still sitting there, and the forwardRequest catch opens with `if (aborted) return`. Nothing was written back. Measured in reverse mode on the real /v1/messages path, against an upstream that refuses instantly, which is what a dead local hop does: before body at once -> HANG (6s client timeout) body delayed -> HANG after body at once -> 502 in 9ms body delayed -> 502 in 52ms Keyed on clientRes's close instead: it fires when the response finishes OR the connection is destroyed, so pairing it with writableEnded separates "we answered" from "the client hung up". Same change in handlePassthrough, which carried the identical line. This was found by measuring an exposure I had already dismissed as too risky to touch. The previous commit recorded it as a latent defect blocking a different fix; it is not latent, it is on the most ordinary upstream failure there is. WHAT THE TESTS DO AND DO NOT GUARD, because the difference matters: the 502 case dies when the listener is reverted to clientReq — mutation-checked the no-leak case does NOT die when the listener is deleted outright Two attempts at the second: client takes a frame then leaves (the pipe tears the upstream down by itself), and an upstream that accepts and never answers so no pipe exists (still freed). Both passed with the listener removed. So the listener may be doing nothing that socket teardown does not already do. It stays — "I could not demonstrate it matters" is not "it does not matter" — and the case is labelled as pinning the PROPERTY, not guarding the listener, so nobody reads it as coverage it is not. ALSO: the relayed probe added in 70ff998 dialled the real api.anthropic.com, because that test never set CACHE_FIX_PROXY_UPSTREAM and the default is the live host. That is the trap integrated.conf line 20 already warns about, and it took CI red on node 22 while bafabae with identical proxy code was green. It now runs against a local 418, on its own instance — pointing config.upstream at loopback for the whole case makes the CONNECT half read the tunnel target as the upstream and stop blind-tunnelling it, which failed the fail-open assertion for an unrelated reason. Ref cnighswonger#304 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cf06dc6 commit a365d5c

3 files changed

Lines changed: 138 additions & 6 deletions

File tree

proxy/server.mjs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,22 @@ async function handleMessages(clientReq, clientRes) {
151151
// Bootstrap (handleBootstrap) doesn't install this because its response is
152152
// a single non-SSE JSON payload — aborting on clientReq close prematurely
153153
// would race the response write on fast-failure paths (e.g. ECONNREFUSED).
154-
clientReq.on("close", () => {
154+
// THE RESPONSE'S close, NOT THE REQUEST'S. Node emits "close" on an
155+
// IncomingMessage when the request BODY has been consumed — which is every
156+
// request, immediately, not only the ones where the client left. So this
157+
// aborted while the client was still sitting there waiting, and the catch
158+
// below opens with `if (aborted) return`, so nothing was ever written back.
159+
//
160+
// Measured in reverse mode against an upstream that refuses instantly, which
161+
// is what a dead local hop does: POST /v1/messages HUNG for the client's full
162+
// 6s timeout instead of answering 502. Both body shapes, sent-at-once and
163+
// sent-delayed. That is a live session stalling on the most ordinary upstream
164+
// failure there is.
165+
//
166+
// clientRes's close fires when the response is finished OR the connection is
167+
// destroyed, so pairing it with writableEnded separates the two: ended means
168+
// we answered, not-ended means the client hung up and the upstream should go.
169+
clientRes.on("close", () => {
155170
if (!clientRes.writableEnded) abortController.abort();
156171
});
157172

@@ -481,7 +496,11 @@ function handleNotFound(_req, res) {
481496
// /v1/messages arrives there), so its 404 contract is unchanged.
482497
async function handlePassthrough(clientReq, clientRes) {
483498
const abortController = new AbortController();
484-
clientReq.on("close", () => { if (!clientRes.writableEnded) abortController.abort(); });
499+
// clientRes, not clientReq — see handleMessages' matching comment. The request
500+
// object's "close" fires when its body is consumed, so this aborted every
501+
// request the instant it arrived and the catch below then returned without
502+
// writing anything.
503+
clientRes.on("close", () => { if (!clientRes.writableEnded) abortController.abort(); });
485504

486505
const method = (clientReq.method || "GET").toUpperCase();
487506
const body = (method === "GET" || method === "HEAD") ? null : await collectBody(clientReq);

test/proxy-forward-attach-fallback.test.mjs

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,15 @@ test("CONNECT falls open to a direct dial, unless CACHE_FIX_REQUIRE_HOP says oth
256256
// Where a direct dial lands. Reaching it is the fail-OPEN outcome.
257257
const direct = net.createServer((sock) => { seen.push("DIRECT"); sock.destroy(); });
258258
const directPort = await listen(direct);
259+
// A LOCAL upstream that answers unmistakably. Without it config.upstream is
260+
// the real https://api.anthropic.com and the relayed probe below dials it for
261+
// real — measured as a CI regression: the run that added this probe went red
262+
// on node 22 while the identical code without it was green, and
263+
// integrated.conf line 20 already warns that an unproxied test here "hangs on
264+
// this network until it times out". 418 is a status nothing else in this
265+
// chain produces, so reaching it cannot be confused with a refusal.
266+
const upstream = http.createServer((_q, r) => { r.writeHead(418); r.end("teapot"); });
267+
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
259268
// A hop address with nothing behind it: the whole chain refuses.
260269
const deadHop = net.createServer();
261270
const deadPort = await listen(deadHop);
@@ -311,6 +320,14 @@ test("CONNECT falls open to a direct dial, unless CACHE_FIX_REQUIRE_HOP says oth
311320
// aborted=true, writableEnded=false, no response, client timed out at 10s.
312321
// Change this assertion the day that abort listener distinguishes "body
313322
// done" from "client gone".
323+
// ITS OWN INSTANCE. Pointing config.upstream at loopback for the whole case
324+
// breaks the CONNECT half above — the forward proxy then reads the tunnel
325+
// target 127.0.0.1:<port> as the upstream host and stops blind-tunnelling
326+
// it, so `seen` came back empty and the fail-open assertion failed for a
327+
// reason that had nothing to do with fail-open.
328+
await handle.close();
329+
process.env.CACHE_FIX_PROXY_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
330+
handle = await startProxy({ port: 0, watch: false });
314331
const relayed = await new Promise((resolve) => {
315332
const r = http.request({ host: "127.0.0.1", port: handle.port, method: "POST",
316333
path: "/v1/messages", headers: { "content-type": "application/json" } },
@@ -319,14 +336,15 @@ test("CONNECT falls open to a direct dial, unless CACHE_FIX_REQUIRE_HOP says oth
319336
r.setTimeout(4_000, () => { r.destroy(); resolve("TIMEOUT"); });
320337
r.end("{}");
321338
});
322-
assert.notEqual(relayed, 502,
323-
"the relayed path now refuses under CACHE_FIX_REQUIRE_HOP — good, but the " +
324-
"comment above and this assertion both describe the OLD state; update them");
339+
assert.equal(relayed, 418,
340+
`the relayed path answered ${relayed} instead of reaching the upstream. 502 ` +
341+
`means CACHE_FIX_REQUIRE_HOP now covers it — good, but the comment above and ` +
342+
`this assertion both describe the OLD state, so update them together`);
325343

326344
} finally {
327345
restoreEnv(saved);
328346
if (handle) await handle.close();
329-
direct.close();
347+
direct.close(); upstream.close();
330348
try { rmSync(caDir, { recursive: true, force: true }); } catch {}
331349
}
332350
});

test/proxy-server.test.mjs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -939,3 +939,98 @@ describe("/health hop reporting", () => {
939939
}
940940
});
941941
});
942+
943+
// THE CLIENT-ABANDON ABORT, IN BOTH DIRECTIONS.
944+
//
945+
// handleMessages installs an abort so a client that gives up mid-SSE frees the
946+
// upstream. It was keyed on clientReq's "close" — which Node emits when the
947+
// request BODY is consumed, i.e. on every request, immediately — so it aborted
948+
// while the client was still waiting, and the forwardRequest catch opens with
949+
// `if (aborted) return`. Nothing was written back.
950+
//
951+
// Measured before the fix, reverse mode, upstream refusing instantly (what a
952+
// dead local hop does): POST /v1/messages hung for the client's full timeout
953+
// instead of answering 502, for both an at-once and a delayed body.
954+
//
955+
// BOTH CASES OR NEITHER. Asserting only the 502 would pass against a build that
956+
// deleted the listener outright, which leaks an upstream connection for every
957+
// abandoned stream — a worse bug, and invisible until the box runs out of
958+
// sockets.
959+
describe("client-abandon abort", () => {
960+
const ENV = ["CACHE_FIX_PROXY_UPSTREAM", "CACHE_FIX_FORWARD_PROXY", "CACHE_FIX_CA_DIR",
961+
"CACHE_FIX_FALLBACK_PROXIES", "CACHE_FIX_UPSTREAM_PROXY",
962+
"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"];
963+
const save = () => Object.fromEntries(ENV.map((k) => [k, process.env[k]]));
964+
const restore = (s) => { for (const [k, v] of Object.entries(s)) {
965+
if (v === undefined) delete process.env[k]; else process.env[k] = v; } };
966+
967+
it("answers 502 when the upstream refuses, instead of hanging the client", async () => {
968+
const saved = save();
969+
const dead = await freePort(); // nothing listening: instant ECONNREFUSED
970+
let h;
971+
try {
972+
for (const k of ENV) delete process.env[k];
973+
process.env.CACHE_FIX_PROXY_UPSTREAM = `http://127.0.0.1:${dead}`;
974+
h = await startProxy({ port: 0, watch: false });
975+
const got = await new Promise((resolve) => {
976+
const r = http.request({ host: "127.0.0.1", port: h.port, method: "POST",
977+
path: "/v1/messages", headers: { "content-type": "application/json" } },
978+
(res) => { res.resume(); res.on("end", () => resolve(res.statusCode)); });
979+
r.on("error", (e) => resolve(`ERR:${e.code}`));
980+
r.setTimeout(6_000, () => { r.destroy(); resolve("HANG"); });
981+
r.end(JSON.stringify({ model: "x", messages: [] }));
982+
});
983+
assert.equal(got, 502,
984+
`a refusing upstream produced ${got} — the client was never answered, ` +
985+
`which is a live session stalling on the most ordinary upstream failure`);
986+
} finally { restore(saved); if (h) await h.close(); }
987+
});
988+
989+
// NO UPSTREAM LEAK WHEN A CLIENT WALKS AWAY — the PROPERTY, and deliberately
990+
// not a claim about which mechanism provides it.
991+
//
992+
// I could not build a case that dies when the abort listener is deleted. Two
993+
// tries: letting the client take a frame then leave (the pipe tears the
994+
// upstream down on its own), and an upstream that accepts and never answers
995+
// so no pipe exists (still freed). Both passed with the listener removed
996+
// outright. So the listener may be doing nothing here that socket teardown
997+
// does not already do — which would make it pure liability, since keying it
998+
// on clientReq is what hung every fast upstream failure.
999+
//
1000+
// It stays, because "I could not demonstrate it matters" is not "it does not
1001+
// matter", and removing it is a bigger change than this evidence supports.
1002+
// This case pins the property so a future refactor that DOES introduce a leak
1003+
// is caught, and says plainly that it is not a guard on the listener.
1004+
it("frees an upstream that has not answered yet when the client walks away", async () => {
1005+
const saved = save();
1006+
let liveUpstream = 0;
1007+
const upstream = http.createServer(() => { /* accept, never respond */ });
1008+
upstream.on("connection", (sock) => {
1009+
liveUpstream++;
1010+
sock.on("close", () => { liveUpstream--; });
1011+
});
1012+
await new Promise((r) => upstream.listen(0, "127.0.0.1", r));
1013+
let h;
1014+
try {
1015+
for (const k of ENV) delete process.env[k];
1016+
process.env.CACHE_FIX_PROXY_UPSTREAM = `http://127.0.0.1:${upstream.address().port}`;
1017+
h = await startProxy({ port: 0, watch: false });
1018+
await new Promise((resolve) => {
1019+
const r = http.request({ host: "127.0.0.1", port: h.port, method: "POST",
1020+
path: "/v1/messages", headers: { "content-type": "application/json" } },
1021+
(res) => { res.resume(); });
1022+
r.on("error", () => {});
1023+
r.end(JSON.stringify({ model: "x", messages: [] }));
1024+
// Long enough for the proxy to have dialled and be WAITING on the
1025+
// upstream — the state this case is about — then walk away.
1026+
setTimeout(() => { r.destroy(); resolve(); }, 400);
1027+
});
1028+
assert.ok(liveUpstream > 0 || true, ""); // the count below is the assertion
1029+
for (let i = 0; i < 40 && liveUpstream > 0; i++) await new Promise((r) => setTimeout(r, 50));
1030+
assert.equal(liveUpstream, 0,
1031+
`the client walked away while the upstream had not answered, and ${liveUpstream} ` +
1032+
`upstream connection(s) stayed open — one leak per abandoned request, with no ` +
1033+
`pipe in place to tear it down`);
1034+
} finally { restore(saved); if (h) await h.close(); await new Promise((r) => upstream.close(r)); }
1035+
});
1036+
});

0 commit comments

Comments
 (0)