Skip to content

Commit bafabae

Browse files
codeslakeclaude
andcommitted
fix: a deploy that changes nothing, and a /health field that names the wrong hop
Twelve findings from the review of this PR, plus one raised by cswap's pin against the fix for the last of them. Each has a test that dies when its fix is reverted. THE DEPLOY ONES, which is why this is not a tidy-up: otherHolderOn() compared process AGE only. Every incumbent outlives a process that just started, so on every deploy the NEW code judged itself surplus, exited 0, and the OLD holder kept serving with nothing saying so. Now a holder is a duplicate only when it is running the same code. holderPidOn() answered "holder" on the mere presence of a run-service, which made runningOurCode() unreachable: the holder always keeps a descriptor to the listening socket, so the loop always returned before the fingerprint branch. bindFailed() read no error code, so a bind that can NEVER work — an address not on this host, a privileged port — took the "someone else has it" path, found no incumbent to ask, and exited 0. A deploy that started nothing reported success. Bind errors also carried libuv's errno through two hardcoded literals that named EADDRINUSE and called everything else EACCES; util.getSystemErrorName is right on both platforms and for every code. The holder matched its child's release announcement against a RAW CHUNK while the port line beside it was line-buffered. A chunk boundary inside "(handed off)" reads a handover as a plain release, so the holder reclaims the port from the successor already serving on it and spawns a second — the failure the (handed off) marker exists to prevent, re-entered through the marker itself. Ownership probes asked lsof about 127.0.0.1 while the bind honoured CACHE_FIX_PROXY_BIND. Under any other address the probe matched nothing. CACHE_FIX_PROXY_PORT=0 was rewritten to the legacy 9801 by `Number(env) || 9801` ("0" is a truthy string), while proxy/config.mjs read the same variable with envInt and yielded 0. THE SHUTDOWN ONES: shutdown() had no re-entry guard although it is bound to SIGTERM, SIGINT and SIGHUP, and a control-group stop delivers more than one. Each entry can put another successor on fd 3. The window only exists while something is draining, which a live session always is. handle.close() always rejected on that path, because shutdown() closes the server one line earlier and the second close reports ERR_SERVER_NOT_RUNNING. Only the process.exit() inside .finally() beat the unhandled-rejection report. THE HOP ONES: /health.https_proxy published a configured candidate. resolveHop() falls THROUGH the chain, so it named ":8118" while CONNECTs left via the second fallback or via nothing at all. It now publishes the hop a resolve actually used, and null when the chain was checked and found dead. cswap's pin raised that the fix left one field carrying two meanings — a URL is either measured or merely configured and a reader cannot tell. Split into https_proxy_measured, and direct_last: a sticky ISO instant of the last direct fall-through, under the name and for the reason the pin uses. A chain flaps back within ~1s, so a point-in-time field cannot report the outage that happened. hopAlive() and parseProxy() defaulted an https:// hop with no explicit port to 80, so a live TLS hop read as dead and the chain fell through past it. CONNECT fell open to a direct dial with no way to refuse. Fail-open stays the default on both ends of the chain — a hop restarting is back in ~1s and refusing strands a session whose HTTPS_PROXY was baked at exec — but CACHE_FIX_REQUIRE_HOP=1 now exists for a deployment where the hop is a policy boundary rather than a cache. Ref cnighswonger#304 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a1fef09 commit bafabae

9 files changed

Lines changed: 695 additions & 35 deletions

bin/claude-via-proxy.mjs

Lines changed: 81 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { X509Certificate, createHash, randomUUID } from "node:crypto";
99
import http from "node:http";
1010
import net from "node:net";
1111
import { EventEmitter } from "node:events";
12+
import { getSystemErrorName } from "node:util";
1213
import { bundleUsable, carriesOurCA, salvageBundle } from "./ca-trust.mjs";
1314

1415
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -152,8 +153,13 @@ class HolderSocket extends EventEmitter {
152153
try { h.close(); } catch { /* never bound */ }
153154
const e = new Error(`bind ${host}:${port} failed`);
154155
// The code net.Server would have emitted, so callers that branch on
155-
// EADDRINUSE keep working.
156-
e.code = err === -98 || err === -48 ? "EADDRINUSE" : "EACCES";
156+
// EADDRINUSE keep working. From libuv's own table rather than the two
157+
// literals it replaced (-98 linux / -48 darwin): those named the in-use
158+
// case on both platforms and called EVERYTHING ELSE "EACCES", so a bind
159+
// address not on this host (EADDRNOTAVAIL) and a privileged port arrived
160+
// indistinguishable — and bindFailed() reads the code to tell "someone
161+
// else is serving" from "this bind can never work".
162+
e.code = getSystemErrorName(err);
157163
queueMicrotask(() => this.emit("error", e));
158164
return this;
159165
}
@@ -347,14 +353,21 @@ class HolderSocket extends EventEmitter {
347353
}
348354
}
349355

356+
// The address the proxy binds, read the same way by the bind and by every
357+
// ownership probe. They disagreed: the probes asked lsof about 127.0.0.1 while
358+
// the bind honoured CACHE_FIX_PROXY_BIND, so with any other bind address lsof
359+
// matched nothing, holderPidOn() answered null, and takeOver() took "cannot
360+
// identify it: leave it alone" and exited 0 beside a live proxy of ours.
361+
const bindAddr = () => process.env.CACHE_FIX_PROXY_BIND || "127.0.0.1";
362+
350363
// Returns "holder" when the owner is a holder of ours (nothing to do), a pid
351364
// when it is something else we may ask to stop, or null when we cannot tell —
352365
// and NULL MEANS LEAVE IT ALONE. Signalling a pid we did not identify is how a
353366
// deploy comes to kill an unrelated service that happened to be on the port.
354367
function holderPidOn(port) {
355368
let out = "";
356369
try {
357-
out = execFileSync("lsof", ["-nP", "-t", `-iTCP@127.0.0.1:${port}`, "-sTCP:LISTEN"],
370+
out = execFileSync("lsof", ["-nP", "-t", `-iTCP@${bindAddr()}:${port}`, "-sTCP:LISTEN"],
358371
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
359372
} catch { return null; }
360373
// EVERY owner, not the first line. The holder keeps a bound descriptor AND a
@@ -366,12 +379,17 @@ function holderPidOn(port) {
366379
// function exists to prevent.
367380
const pids = out.trim().split("\n").map(Number).filter((n) => Number.isInteger(n) && n > 1);
368381
if (!pids.length) return null;
369-
// A holder among them settles it: it is ours and it is already serving.
382+
// A holder among them settles it ONLY IF IT RUNS OUR CODE. Returning "holder"
383+
// on the mere presence of a run-service made runningOurCode() dead: the holder
384+
// always keeps a descriptor to the listening socket, so it is always in this
385+
// list, so this loop always returned before the fingerprint branch below.
386+
// Measured: a deploy printed "this one is surplus", exited 0, and left the OLD
387+
// code serving — every upgrade a no-op.
370388
for (const p of pids) {
371389
try {
372390
const c = execFileSync("ps", ["-p", String(p), "-o", "command="],
373391
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
374-
if (/\brun-service\b/.test(c)) return "holder";
392+
if (/\brun-service\b/.test(c)) return runningOurCode(port) ? "holder" : p;
375393
} catch { /* gone between lsof and ps */ }
376394
}
377395
// NOT THE STANDBY, unless it is all there is. lsof returns ascending pid order
@@ -437,7 +455,7 @@ function holderPidOn(port) {
437455
function otherHolderOn(port) {
438456
let pids = [];
439457
try {
440-
pids = execFileSync("lsof", ["-nP", "-t", `-iTCP@127.0.0.1:${port}`, "-sTCP:LISTEN"],
458+
pids = execFileSync("lsof", ["-nP", "-t", `-iTCP@${bindAddr()}:${port}`, "-sTCP:LISTEN"],
441459
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
442460
.trim().split("\n").map(Number).filter((n) => Number.isInteger(n) && n > 1 && n !== process.pid);
443461
} catch { return 0; }
@@ -452,7 +470,14 @@ function otherHolderOn(port) {
452470
// process has been up; a tie goes to the incumbent, which is the safe way
453471
// round — the surplus one leaving is free, two holders is not.
454472
const theirs = Number(line.split(/\s+/)[0]);
455-
if (Number.isFinite(theirs) && theirs >= Math.floor(process.uptime())) return p;
473+
if (!Number.isFinite(theirs) || theirs < Math.floor(process.uptime())) continue;
474+
// AGE IS NOT ENOUGH. Every incumbent outlives a process that just started,
475+
// so age alone fired on every deploy: the NEW code called itself surplus and
476+
// left, and the old holder kept serving with nothing saying so. Only a
477+
// holder running the SAME code is a duplicate; a different one is what the
478+
// deploy exists to replace.
479+
if (!runningOurCode(port)) continue;
480+
return p;
456481
}
457482
return 0;
458483
}
@@ -526,8 +551,15 @@ function runningOurCode(port) {
526551
function holdPort(rest) {
527552
// The proxy's own default: holding a different port than the proxy would have
528553
// served leaves nothing at the documented address.
529-
const port = Number(process.env.CACHE_FIX_PROXY_PORT) || 9801;
530-
const bind = process.env.CACHE_FIX_PROXY_BIND || "127.0.0.1";
554+
// `|| 9801` REWROTE PORT 0 to 9801. "0" is a truthy string so the run-service
555+
// guard let it through, and this line then bound the LEGACY port — measured:
556+
// `CACHE_FIX_PROXY_PORT=0 run-service` listening on 127.0.0.1:9801, the "took
557+
// 9801 while the fleet dialled 9901" failure that guard exists to prevent.
558+
// proxy/config.mjs reads the same variable with envInt and yields 0.
559+
const rawPort = process.env.CACHE_FIX_PROXY_PORT;
560+
const port = rawPort === undefined || rawPort === "" || Number.isNaN(Number(rawPort))
561+
? 9801 : Number(rawPort);
562+
const bind = bindAddr();
531563

532564
return new Promise((resolveP) => {
533565
let child = null, childPort = 0, stopping = false, restart = null, failures = 0, served = false;
@@ -869,18 +901,22 @@ function holdPort(rest) {
869901
// owns the accept path; we keep the descriptor so we can start the next
870902
// one, including after a crash. Deploys measured at 149,038 / 148,658 /
871903
// 146,225 requests, zero lost, zero refused, zero reset.
872-
// Buffered until a newline: the port arrives on stdout, and a chunk
873-
// boundary inside that line would otherwise lose it silently — every
874-
// connection would then wait out the relay's deadline.
875-
let line = "";
876-
me.stdout.on("data", (chunk) => {
877-
process.stdout.write(chunk);
904+
// WHOLE LINES, for every announcement and not just the port. Buffering
905+
// was added for the port line — a chunk boundary inside it loses the
906+
// port silently and every connection then waits out the relay's deadline
907+
// — and the release test was left reading the raw chunk. Same defect,
908+
// worse outcome: a boundary between "…listening socket" and "(handed
909+
// off)" reads a handover as a plain release, so this holder reclaims the
910+
// port from the successor already serving on it and spawns a second one.
911+
// That is the "one extra proxy per deploy, 3 alive after 4" the (handed
912+
// off) test exists to prevent, re-entered through the test itself.
913+
const onLine = (line) => {
878914
// The proxy announces the release before it drains, so the port comes
879915
// back to us at the START of its shutdown rather than at its exit.
880916
// Retire it here: it is no longer the proxy this holder supervises, so
881917
// the successor can boot while it finishes its in-flight work, and its
882918
// eventual exit must not be read as a death needing a respawn.
883-
if (!retired && String(chunk).includes("releasing the listening socket")) {
919+
if (!retired && line.includes("releasing the listening socket")) {
884920
retired = true;
885921
if (child === me) child = null;
886922
// "(handed off)" means the proxy already put its own successor on the
@@ -889,7 +925,7 @@ function holdPort(rest) {
889925
// add a second — measured without this: one extra proxy per deploy,
890926
// 3 alive after 4 deploys. Nothing to do but stop supervising the
891927
// one that left.
892-
if (String(chunk).includes("(handed off)")) return;
928+
if (line.includes("(handed off)")) return;
893929
reclaim();
894930
// AND ask for the successor. reclaim() only starts one if its bind
895931
// lands after this point; when the port is already ours — a proxy
@@ -900,10 +936,9 @@ function holdPort(rest) {
900936
spawnWhenReady();
901937
}
902938
if (childPort) return;
903-
line += chunk;
904-
const m = /listening on [\d.]+:(\d+)\n/.exec(line);
939+
const m = /listening on [\d.]+:(\d+)$/.exec(line);
905940
if (m) {
906-
childPort = Number(m[1]); served = true; failures = 0; line = "";
941+
childPort = Number(m[1]); served = true; failures = 0;
907942
// The proxy has generated its CA by the time it says this, so publish
908943
// it now. A host wired by rc starts the proxy HERE and launches claude
909944
// from the shell, so the --remote-control path that used to be the
@@ -912,7 +947,21 @@ function holdPort(rest) {
912947
// terminates no TLS, so it has no CA to offer.
913948
if (process.env.CACHE_FIX_FORWARD_PROXY === "on") publishOurCA(ourCAPath());
914949
}
915-
else if (line.length > 4096) line = line.slice(-256);
950+
};
951+
let buf = "";
952+
me.stdout.on("data", (chunk) => {
953+
process.stdout.write(chunk);
954+
buf += chunk;
955+
// Both announcements end in "\n" (server.mjs `say`), so a complete line
956+
// is the whole fact and a partial one is never acted on.
957+
for (let nl; (nl = buf.indexOf("\n")) !== -1;) {
958+
const line = buf.slice(0, nl);
959+
buf = buf.slice(nl + 1);
960+
onLine(line);
961+
}
962+
// A child that writes megabytes without a newline must not grow this
963+
// without bound; keep only enough tail to finish a split announcement.
964+
if (buf.length > 4096) buf = buf.slice(-256);
916965
});
917966
me.on("error", (err) => {
918967
process.stderr.write(`Failed to start proxy server: ${err.message}\n`);
@@ -1022,8 +1071,18 @@ function holdPort(rest) {
10221071
// A later server error must not start a second proxy beside the first.
10231072
// Under run-service the collision is the ANSWER, not a fallback: something
10241073
// is already serving, which is all the caller asked for.
1025-
const bindFailed = () => {
1074+
const bindFailed = (e) => {
10261075
holder.off("error", bindFailed);
1076+
// ONLY EADDRINUSE MEANS "SOMEONE ELSE HAS IT". Every other bind failure —
1077+
// EADDRNOTAVAIL from a CACHE_FIX_PROXY_BIND that is not an address here,
1078+
// EACCES on a privileged port as non-root — went down the same path:
1079+
// takeOver() found no listener to identify, hit "cannot identify it:
1080+
// leave it alone" and exited 0. A deploy that started nothing reported
1081+
// success, and deploy.sh has no way to tell that from a real no-op.
1082+
if (e?.code && e.code !== "EADDRINUSE") {
1083+
process.stderr.write(`[cache-fix] cannot bind ${bind}:${port}${e.code}\n`);
1084+
return settle(1);
1085+
}
10271086
if (alreadyRunning) return takeOver();
10281087
resolveP(runProxy(rest));
10291088
};

proxy/forward-proxy.mjs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,10 @@ export function ensureCA() {
241241
// Parse an http(s)://host:port proxy URL into { host, port }.
242242
function parseProxy(url) {
243243
if (!url) return null;
244-
try { const u = new URL(url); return { host: u.hostname, port: Number(u.port) || 80 }; }
244+
// Scheme-defaulted, like hopAlive(): `|| 80` sent the CONNECT for an
245+
// `https://hop` carrying no explicit port to :80, so the tunnel died against
246+
// a hop hopAlive() had just confirmed on :443.
247+
try { const u = new URL(url); return { host: u.hostname, port: Number(u.port) || (u.protocol === "https:" ? 443 : 80) }; }
245248
catch { return null; }
246249
}
247250

@@ -262,6 +265,18 @@ function parseProxy(url) {
262265
// hanging.
263266
const hopFor = async () => parseProxy(await resolveHop(true));
264267

268+
// Falling open — dialling the target directly when no chain hop answers — is
269+
// the DEFAULT and stays that way: a hop restarting is back in ~1s, and refusing
270+
// meanwhile strands a session whose HTTPS_PROXY was baked at exec, which is the
271+
// outage this whole chain exists to avoid.
272+
//
273+
// It is wrong where the hop is a POLICY boundary rather than a cache. There the
274+
// direct dial is a silent bypass: the client gets "200 Connection Established"
275+
// and cannot tell it left unproxied, and until /health started publishing the
276+
// resolved hop nothing downstream could either. One opt-in, off by default, so
277+
// the deployment that needs fail-closed can say so instead of discovering it.
278+
const requireHop = () => process.env.CACHE_FIX_REQUIRE_HOP === "1";
279+
265280
// Blind-tunnel a CONNECT to `target` (host:port) untouched. Routes through the
266281
// resolved hop when there is one, else dials the target directly. No TLS
267282
// termination; bytes pass through opaque.
@@ -272,6 +287,11 @@ async function blindTunnel(target, clientSocket, head) {
272287
// The client may have given up while we probed the chain; dialling for a
273288
// dead socket leaks the upstream connection.
274289
if (clientSocket.destroyed) return;
290+
if (!via && requireHop()) {
291+
process.stderr.write(`[forward-proxy] no chain hop reachable and CACHE_FIX_REQUIRE_HOP=1 — refusing ${target}\n`);
292+
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
293+
return;
294+
}
275295
const onUpstream = (upstream) => {
276296
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
277297
if (head && head.length) upstream.write(head);
@@ -321,6 +341,7 @@ async function connectUpstreamTLS(cb, onErr) {
321341
// Same chain as the blind tunnel above — see hopFor().
322342
let via;
323343
try { via = await hopFor(); } catch (err) { return onErr(err); }
344+
if (!via && requireHop()) return onErr(new Error("no chain hop reachable (CACHE_FIX_REQUIRE_HOP=1)"));
324345
if (via) {
325346
const r = http.request({ host: via.host, port: via.port, method: "CONNECT",
326347
path: `${upHost}:${upPort}`, headers: { host: `${upHost}:${upPort}` } });

proxy/server.mjs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { createHash } from "node:crypto";
33
import https from "node:https";
44
import { pathToFileURL, URL } from "node:url";
55
import config from "./config.mjs";
6-
import { forwardRequest, parseAbsoluteForm, getAgent, fallbackProxyUrls } from "./upstream.mjs";
6+
import { forwardRequest, parseAbsoluteForm, getAgent, fallbackProxyUrls, lastHop, directLast } from "./upstream.mjs";
77
import { streamResponse, createTelemetryRecord } from "./stream.mjs";
88
import { loadExtensions, snapshotRegistry, runOnRequest, runOnResponseStart, runOnResponse, getFailedExtensions } from "./pipeline.mjs";
99
import { startWatcher } from "./watcher.mjs";
@@ -398,7 +398,33 @@ function handleHealth(_req, res) {
398398
status: "ok",
399399
version: config.version,
400400
forward_proxy: _forwardActive > 0,
401-
https_proxy: (_forwardActive > 0 && hopAddress(config.httpsProxy || fallbackProxyUrls()[0])) || null,
401+
// THE HOP IN USE ONCE THERE IS ONE. resolveHop() falls THROUGH the chain
402+
// and can end at a direct dial, so naming candidate #1 published ":8118"
403+
// while CONNECTs left via the second fallback — or via nothing at all. Same
404+
// class of lie as the config.httpsProxy-only read above it, one step
405+
// further along.
406+
//
407+
// The three states are distinct and only two of them are a claim about a
408+
// dial: a URL is the hop the last one took; "" is "we checked the whole
409+
// chain and nothing answered", which must publish null rather than a
410+
// candidate; `undefined` is a proxy that has dialled nothing yet — every
411+
// successor is in that state for its first request — and there the
412+
// configured candidate is the only thing known and asserts nothing false.
413+
https_proxy: (_forwardActive > 0 && hopAddress(
414+
lastHop() ?? (config.httpsProxy || fallbackProxyUrls()[0]))) || null,
415+
// MEASURED OR MERELY CONFIGURED. The line above publishes a URL in two
416+
// different situations — the hop a resolve actually used, and the first
417+
// candidate on a proxy that has dialled nothing yet — and a reader cannot
418+
// tell them apart from the string. cswap's pin raised exactly this against
419+
// the fix above: one field carrying two meanings is the same defect as the
420+
// one being fixed, and its confirm logic would have to guess.
421+
//
422+
// So: true = that address was used, false = it is a candidate. `null` in
423+
// https_proxy needs no flag; it already means "checked, nothing reachable".
424+
https_proxy_measured: _forwardActive > 0 && !!lastHop(),
425+
// Sticky: when the chain last fell through to a direct dial (never = null).
426+
// See directLast() — a point-in-time field cannot report a flap.
427+
direct_last: directLast(),
402428
// Content fingerprint of the source this process LOADED. Hot-reload is
403429
// off, so after an edit without a restart this stays at the old value
404430
// while the working tree moves on — which is precisely the drift an
@@ -897,7 +923,15 @@ export async function startProxy(options = {}) {
897923
try {
898924
if (watcher) watcher.close();
899925
} catch {}
900-
server.close((err) => (err ? reject(err) : resolve()));
926+
// ERR_SERVER_NOT_RUNNING is not a failure HERE. shutdown() unbinds
927+
// first — announcing while we still hold the socket makes the
928+
// supervisor race a bind it must lose — and then drains through this,
929+
// so the second close always reports "not running" and this promise
930+
// ALWAYS rejected on the one path that calls it. Nothing handles that
931+
// rejection; only the process.exit() inside .finally() beat the
932+
// unhandled-rejection report to it. Both callbacks fire on the same
933+
// 'close' event, after the drain, so resolving is the true answer.
934+
server.close((err) => (err && err.code !== "ERR_SERVER_NOT_RUNNING" ? reject(err) : resolve()));
901935
}),
902936
};
903937
}
@@ -1220,7 +1254,16 @@ if (invokedAsScript) {
12201254
// "status=1/FAILURE", which (a) makes a crash and a clean stop
12211255
// indistinguishable in the journal and (b) trips Restart=on-failure on a
12221256
// deliberate stop. Force the laggards, report the forcing on stderr, exit 0.
1257+
// ONCE. SIGTERM, SIGINT and SIGHUP all land here, and a supervised stop
1258+
// delivers more than one: systemd SIGTERMs the whole control group, so the
1259+
// proxy gets it directly AND the holder forwards its own SIGHUP. Re-entering
1260+
// spawns a SECOND successor on fd 3 — two proxies on one socket, which is the
1261+
// "one extra per deploy" the (handed off) announcement exists to stop — and
1262+
// announces the release twice, and arms a second 5s force-close.
1263+
let shuttingDown = false;
12231264
const shutdown = () => {
1265+
if (shuttingDown) return;
1266+
shuttingDown = true;
12241267
if (!active) {
12251268
process.exit(0);
12261269
return;

0 commit comments

Comments
 (0)