You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: CHANGELOG.md
+10Lines changed: 10 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -10,6 +10,16 @@
10
10
11
11
### Fixed
12
12
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
+
13
23
-**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.
14
24
15
25
-**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.
Copy file name to clipboardExpand all lines: README.md
+3Lines changed: 3 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -450,6 +450,9 @@ All proxy settings are controlled via environment variables. Set them before sta
450
450
|`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). |
451
451
|`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`. |
452
452
|`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. |
453
456
|`CACHE_FIX_EXTENSIONS_DIR`|`proxy/extensions/`| Directory for extension `.mjs` files |
0 commit comments