Skip to content

Commit ce8a5a1

Browse files
codeslakeclaude
andcommitted
merge pr-356
Resolved test/proxy-held-port.test.mjs, both hunks to the incumbent side. The first is a third wording of one type guard in classify(); all three are behaviourally identical, and the one kept names the mechanism (freePort releases a port before its caller binds it, so a neighbour's 200 arrives as a Number) rather than the single 502 that surfaced it. The second is not a wording difference: cnighswonger#356 predates the two cases pr-345 added here (the port-1 refusal probe and readyBody), so taking its side would delete them. Kept. Co-Authored-By: Claude <noreply@anthropic.com>
2 parents cd57cb5 + 14c2587 commit ce8a5a1

13 files changed

Lines changed: 2459 additions & 85 deletions

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010

1111
### Fixed
1212

13+
- **The two drain budgets reject a value they used to read as zero, and the route tally no longer renders an authority.** `Number(x) || fallback` took the fallback for an explicit `0` and passed a NEGATIVE straight through, and a negative stall budget ends every owed connection on the first tick -- the guillotine this drain replaced. Tightening that to a finite non-negative number then left a second hole in the same place: `Number()` reads whitespace as 0, so `CACHE_FIX_DRAIN_STALL_MS=" "` in an env file was the same guillotine, measured severing a healthy 3-second-gap stream. Both knobs now take a plain non-negative decimal and fall back otherwise. Separately, `drainRoute` guarded on the request-target's SHAPE, which cannot enforce a content rule: `/http://user:pass@host/v1`, a backslash, a percent-encoded `//` and a `#` fragment are each one leading slash and each put an authority in a log line that outlives the process. It now judges the label it is about to write.
14+
15+
- **A drain no longer severs a reply that had finished but not flushed, and no longer keeps the port in order to deliver it.** Node counts a response whose `end()` has been CALLED as idle, and `http.Server.close()` runs that idle sweep *before* it unbinds — so a reply that was complete but still queued was destroyed at drain start: measured 4,217,623 bytes delivered of a declared 16,777,216, with `drained clean` printed for it. Discriminated on plain Node across four arms — no close 100% delivered, `http.Server.close()` 24.9%, the same plus an explicit sweep 24.9%, `net.Server.prototype.close` 100%, all of them refusing new connections afterwards — the sweep is the agent and the unbind costs nothing. The listening socket is therefore released at once and only the sweep waits for the flush; waiting with the unbind instead held the address for the whole drain budget, after the release had already been announced and a holder had settled on it.
16+
17+
- **A handover drain now ends stalled connections one at a time instead of judging the whole port.** The stall test kept a single timestamp and reset it whenever ANY owed connection moved a byte, so on a port with traffic one live stream held the clock open and a genuinely stalled reply was never aged out — measured, it polled for a full 1800 s answering "still moving" and then cut six replies on the backstop. Each owed connection now carries its own last-byte stamp, dated from when the request ARRIVED while nothing has left its socket since, and one quiet for `CACHE_FIX_DRAIN_STALL_MS` (default 90000) is ended alone while the rest keep delivering. The cut moved with the clock: ending the whole drain on the first quiet connection would take the live ones with it. A response is reset rather than closed unless bytes actually reached the client, because `res.end()` on a header-less reply emits a well-formed empty `200` a client cannot distinguish from a real one and will not retry. The forced-close line no longer counts a connection the stall test already ended, and a drain that ended replies no longer calls itself "clean". The 5 s ceiling now applies to the STANDALONE arm alone -- a proxy with nothing supervising it, where the process draining is the process the service manager is waiting on. A stop under a live holder no longer takes it; see the entry below for what that costs.
18+
19+
- **A holder handover no longer freezes the config the outgoing holder booted with.** `SIGUSR2` passes the listening socket to a successor spawned with `{ ...process.env }` — faithful, and therefore stale: a `CACHE_FIX_*` switch added after a holder started could not reach it without releasing the address, and releasing it cuts whatever is streaming across the gap. The successor now re-reads `CACHE_FIX_*` settings from `${CLAUDE_CONFIG_DIR:-~/.claude}/cache-fix-handover.env` (relocate with `CACHE_FIX_HANDOVER_ENV`) and lets them win over what it inherited. Every failure is off — absent, unreadable or malformed leaves the inherited value standing, since a handover that refuses over a bad config file is worse than one carrying a stale switch. A line is honoured only once its newline has been written, so a handover that catches the file half-written cannot pick up a truncated value: `CACHE_FIX_PROXY_UPSTREAM=http://ho` is a well-formed assignment and nothing later corrects it. The port, the bind and the orphan guard stay pinned by the holder: the successor *adopts* the socket, so a bind written here could not move it but would mislabel `CACHE_FIX_HELD_HOST`, the proxy child and `/health`. Not covered: stdio. `inherit` passes file descriptors, not environment, so a lineage started with its stderr on `/dev/null` keeps it however this file is written.
20+
21+
- **A stop under a holder no longer severs the reply it is delivering, and the holder no longer waits for it.** The holder settled on its child's EXIT, so every second the child spent draining was a second `systemctl stop` was blocked -- which is what forced a 5 s ceiling onto the child and cut 15 replies across four stops on one host (4, 3, 1, 7). The holder now settles on the proxy's RELEASE announcement, which the proxy makes *before* it drains and after the listening socket is already free, so the port is available to a successor in under a second while the outgoing proxy finishes what it owes. **The operational trade is a resident drainer:** a stop returns immediately and leaves the old proxy alive, holding no listener and no lock, until its replies finish or `CACHE_FIX_DRAIN_MS` expires -- up to 30 minutes by default, one process per stop. It exits 0, so a supervisor that restarts on failure does not restart it. The holder also signals that child `SIGUSR2` rather than `SIGHUP` on a handover: `forward()` rewrites every stop to `SIGHUP`, so one word could not mean both "a successor is already serving" and "the supervisor is stopping us", and those want opposite budgets.
22+
1323
- **A refused fd-3 handover no longer makes the proxy claim it handed the socket on.** `inheritedSocket` was computed from "handover was attempted", not "handover succeeded", so a proxy that was refused fd 3 and fell back to binding its own port still advertised an inherited socket. On `SIGTERM` it then spawned a successor pointed at the same unservable descriptor and exited `75` — telling the supervisor a successor holds the socket — while the port it actually served was released with nobody on it. Exits `0` now, spawns nothing, and leaves no orphan.
1424

1525
- **A dead log reader no longer kills the supervisor or the relay.** The proxy gained an EPIPE swallower after a measured 27-minute outage; the launcher's holder and the gap relay share the same pipe and never got one, so the same killed reader took down the process whose whole job is to put the proxy back. Both now install stream `'error'` listeners, as does the proxy in reverse mode, where its own guard had been attached only when forward mode was active. The interactive wrapper deliberately keeps the old behaviour — a foreground producer whose consumer dies should end.

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,9 @@ All proxy settings are controlled via environment variables. Set them before sta
450450
| `CACHE_FIX_FORWARD_PROXY` | unset | Set to `on` for forward-proxy mode (HTTP CONNECT + selective MITM of the upstream host) so the client points `HTTPS_PROXY` at the proxy instead of `ANTHROPIC_BASE_URL`, keeping Remote Control enabled. See [Forward-proxy mode](#forward-proxy-mode-keeps-remote-control-working). |
451451
| `CACHE_FIX_CA_DIR` | `~/.claude/cache-fix-ca` | Directory for the forward-proxy CA/leaf cert (generated once on first start). The client trusts `ca.pem` via `NODE_EXTRA_CA_CERTS`. |
452452
| `CACHE_FIX_PROXY_TIMEOUT` | `600000` | Request timeout in milliseconds |
453+
| `CACHE_FIX_DRAIN_MS` | `1800000` | Backstop for a handover drain: how long the departing proxy keeps delivering replies it still owes after it has released the listening socket. It is a re-evaluation point rather than a deadline — a connection still writing bytes when it expires keeps the drain alive and is reported every 60 s, because the thing on the other side of that branch is a reply somebody is reading. Applies to the handover arm only; a stop with nothing supervising the proxy keeps a fixed 5 s ceiling, since there the process draining IS the process the service manager is waiting on. Parsed the same way as `CACHE_FIX_DRAIN_STALL_MS` above. |
454+
| `CACHE_FIX_DRAIN_STALL_MS` | 90000 | How long an in-flight reply may go with **no byte written** before a handover drain ends that one connection. Paired with `CACHE_FIX_DRAIN_MS` (the drain's backstop, default 1800000), which bounds the whole drain rather than any connection. The default is a judgement bounded by two observations, not a percentile: content-free waits reach ~186 s while byte-free waits reach ~23 s on a busy stream, so 90000 sits past any gap a live reply produces and short of the ten minutes that was cutting real work. **Do not tighten it toward an observed maximum** — that maximum is not stable, and anything under ~60000 is inside the range of a healthy stream on a slow link. Raise it if your upstream is slower than the samples behind the default. Accepted values are a plain non-negative decimal, optionally padded; anything else -- whitespace, a negative, `1e3`, `0x10` -- falls back to the default rather than being guessed at. `Number()` alone reads whitespace as 0, and 0 here is an immediate cut. |
455+
| `CACHE_FIX_HANDOVER_ENV` | `~/.claude/cache-fix-handover.env` | File the holder re-reads when it hands its listening socket to a successor on `SIGUSR2`, so a `CACHE_FIX_*` setting can reach a running fleet without dropping the port. One `KEY=value` per line, each terminated by a newline — no quoting, no `export`; only `CACHE_FIX_*` keys are read, and every failure (absent, unreadable, malformed) leaves the inherited value standing. An unterminated last line is ignored, so a handover that catches the file half-written cannot pick up a truncated value. Settings only — the holder pins the port, the bind and `CACHE_FIX_STANDBY` over anything written here, and sets the `CACHE_FIX_HELD_*` lineage keys itself at each spawn, so a bind change still needs a real restart. **Write access to this file is control of the proxy** — it can set `CACHE_FIX_PROXY_UPSTREAM` and `CACHE_FIX_REQUEST_CAPTURE`, so keep its permissions as tight as the config dir's. |
453456
| `CACHE_FIX_EXTENSIONS_DIR` | `proxy/extensions/` | Directory for extension `.mjs` files |
454457
| `CACHE_FIX_EXTENSIONS_CONFIG` | `proxy/extensions.json` | Extension configuration file |
455458
| `CACHE_FIX_DEBUG` | `0` | Enable debug logging |

bin/claude-via-proxy.mjs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import net from "node:net";
1111
import { EventEmitter } from "node:events";
1212
import { getSystemErrorName } from "node:util";
1313
import { bundleUsable, carriesOurCA, salvageBundle } from "./ca-trust.mjs";
14+
import { handoverEnv, handoverEnvPath } from "./handover-env.mjs";
1415
import { sourceFingerprintSync } from "../proxy/source-fingerprint.mjs";
1516

1617
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -1072,8 +1073,21 @@ function holdPort(rest) {
10721073
// 9901."
10731074
//
10741075
// We are bound to the number, so there is nothing to guess.
1075-
env: { ...process.env, CACHE_FIX_HOLDER_HANDOVER: "1", LISTEN_FDS: "1",
1076+
env: { ...handoverEnv(process.env), CACHE_FIX_HOLDER_HANDOVER: "1", LISTEN_FDS: "1",
10761077
CACHE_FIX_PROXY_PORT: String(holder._port || port),
1078+
// AND THE BIND: the successor ADOPTS this socket, so a bind from
1079+
// the file cannot move it but would still relabel _host, and every
1080+
// downstream name (HELD_HOST, the proxy child, /health) with it.
1081+
CACHE_FIX_PROXY_BIND: bindAddr(),
1082+
// AND THE PATH TO THE FILE, or the file moves its own trust
1083+
// anchor: a CACHE_FIX_HANDOVER_ENV written there points every
1084+
// later handover somewhere else, and since absence means
1085+
// inherit, reverting the original file cannot take it back.
1086+
CACHE_FIX_HANDOVER_ENV: handoverEnvPath(),
1087+
// AND A HOLDER IS NOT A STANDBY. openGap() sheds HOLDER_TREE and
1088+
// HELD_BY the same way; this one only became reachable when the
1089+
// env above stopped being ours alone.
1090+
CACHE_FIX_STANDBY: undefined,
10771091
CACHE_FIX_EXIT_WITH_PARENT: "0" },
10781092
});
10791093
// WE LEAVE WHEN THE SUCCESSOR EXISTS, not when we have asked for one.
@@ -1103,11 +1117,24 @@ function holdPort(rest) {
11031117
// mutually exclusive, so nothing reaches recovery after departure today;
11041118
// it is kept so a future reordering cannot quietly re-arm a holder that
11051119
// has already handed the address on.
1120+
// SIGUSR2 IS THE HANDOVER'S OWN WORD, and it has to be its own: this
1121+
// block rewrites every stop to SIGHUP, so SIGHUP cannot also mean "a
1122+
// successor is already serving". The proxy picks its drain budget on
1123+
// the difference — 5 s where a supervisor waits serially, half an hour
1124+
// where nothing waits on it at all.
1125+
//
1126+
// SAFE ONLY BECAUSE `child` IS ALWAYS OURS. A proxy with no SIGUSR2
1127+
// handler takes node's default and dies outright, cutting everything
1128+
// with no drain — the hazard deploy.sh guards with a holder_tree check
1129+
// before signalling a holder it did not start. Here the only assignment
1130+
// that creates a child spawns SERVER_PATH from beside this file, so it
1131+
// cannot predate the handler. Adopting a proxy we did not spawn would
1132+
// make this line fatal.
11061133
successor.once("spawn", () => {
11071134
// The child under us keeps serving until IT is replaced by the
11081135
// successor's own child; nothing here interrupts the accept path.
11091136
if (child && child.exitCode === null && !child.signalCode) {
1110-
try { child.kill("SIGHUP"); } catch { }
1137+
try { child.kill("SIGUSR2"); } catch { }
11111138
}
11121139
left = true;
11131140
settle(0);
@@ -1431,6 +1458,24 @@ function holdPort(rest) {
14311458
if (!retired && line.includes("releasing the listening socket")) {
14321459
retired = true;
14331460
if (child === me) child = null;
1461+
// A STOP ENDS HERE, NOT AT THE CHILD'S EXIT. The proxy has just closed
1462+
// its listening socket; from this line it owns nothing but the replies
1463+
// it still owes. Waiting for those is what forced a ceiling onto the
1464+
// child — it could not afford patience while a stop was blocked on it.
1465+
// Its budget keys on being held (server.mjs `unwaited`), so the two
1466+
// cannot be separated.
1467+
//
1468+
// Two cross-file invariants make the orphaned drainer safe, and
1469+
// nothing else records either:
1470+
// - a released proxy must not resurrect the lineage
1471+
// (server.mjs `if (releasingPort) return`), or the orphan returns
1472+
// as a rival holder
1473+
// - it must exit 0, not 75. A launchd agent with
1474+
// `KeepAlive = { SuccessfulExit = false }` restarts a non-zero exit
1475+
//
1476+
// reclaim() and spawnWhenReady() below already return early while
1477+
// stopping, so this returns past nothing.
1478+
if (stopping) return settle(0);
14341479
// "(handed off)" means the proxy already put its own successor on the
14351480
// socket before announcing, and that successor is serving right now.
14361481
// Reclaiming would take the port from a live proxy and spawning would

bin/handover-env.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { join } from "node:path";
2+
import { readFileSync } from "node:fs";
3+
import { claudeHome } from "../proxy/claude-home.mjs";
4+
5+
// A handover keeps the port alive and, until this existed, kept the CONFIG the
6+
// outgoing holder booted with too — so a switch added afterwards could not reach
7+
// the fleet without dropping the address, and dropping it cuts whatever streams
8+
// across the gap. Re-read CACHE_FIX_* from a file the holder does not own.
9+
//
10+
// EVERY FAILURE IS OFF. Absent, unreadable, malformed, unrecognised: the
11+
// inherited value stands. A handover that refuses because a config file is bad
12+
// is worse than one carrying a stale switch.
13+
//
14+
// Only CACHE_FIX_ keys are honoured, which keeps PATH and LD_PRELOAD out and is
15+
// the limit of what the prefix buys. It is NOT a lower trust level: within the
16+
// prefix this file can set CACHE_FIX_PROXY_UPSTREAM, CACHE_FIX_REQUEST_CAPTURE
17+
// and CACHE_FIX_PROXY_CA_FILE, so write access to it is control of the proxy.
18+
//
19+
// ABSENCE MEANS INHERIT, NEVER UNSET. There is no syntax here that removes a
20+
// key, so deleting a line does not turn a switch off — the value the outgoing
21+
// holder carries stands, and one set through this file is then sticky across
22+
// every later handover. Turn a switch off by its own off value
23+
// (`CACHE_FIX_REQUEST_CAPTURE=0`), not by removing the line.
24+
//
25+
// Own module: the launcher is an executable, so importing it runs the CLI.
26+
// Parsed by hand, not util.parseEnv: measured undefined on node 18, which
27+
// package.json declares as the minimum — there the call throws, outside the try.
28+
export const handoverEnvPath = () =>
29+
process.env.CACHE_FIX_HANDOVER_ENV || join(claudeHome(), "cache-fix-handover.env");
30+
31+
export function handoverEnv(base, path = handoverEnvPath()) {
32+
let text;
33+
try { text = readFileSync(path, "utf8"); } catch { return base; }
34+
const out = { ...base };
35+
// A LINE IS HONOURED ONCE ITS TERMINATOR IS THERE. A half-written file parses
36+
// cleanly -- `..._UPSTREAM=http://ho` is a well-formed assignment with a broken
37+
// value -- so dropping the unterminated tail is what makes a truncated write
38+
// invisible here. It also means a file with no final newline loses its last
39+
// line, which is the fail-safe direction: the inherited value stands.
40+
for (const line of text.split("\n").slice(0, -1)) {
41+
const eq = line.indexOf("=");
42+
if (eq < 1) continue;
43+
const k = line.slice(0, eq).trim();
44+
if (!k.startsWith("CACHE_FIX_")) continue;
45+
out[k] = line.slice(eq + 1).trim();
46+
}
47+
return out;
48+
}

0 commit comments

Comments
 (0)