Files
felhom.eu/documentation/audits/SPIKE-ep0-established-connections-2026-08-20.md
T
admin 910fd91124
gates / gates (push) Successful in 14s
agent 0.130.0 published and vouched; R-347 closed, R-349 + R-350 filed
Released via scripts/release-agent.sh: tag v0.130.0 at 7569f34, sha256
a56a92a7bd68f5b46736eaec4806c3d26c16ccb35118c4ac0e3d8094eaefabc3,
verified by independent download and reproducible byte for byte with
-trimpath -buildvcs=false.

Vouched agent 0.129.0 -> 0.130.0 in the Day-0 manifest. Only the agent
fields changed: min_agent stays 0.129.0 because it states what the GOLDEN
CONTROLLER requires, and raising it would have HELD the floor for every
box below 0.130.0. Global floor untouched at 0.216.0 -- and on hub
v0.106.0 it is a separate form with its own action, so publish-train
rule 2's hazard no longer exists in the shape its incident describes.

No --no-verify: the CHANGELOG heading was flipped only after the tag and
package existed, so release-complete passes on the real artifact.

R-349: the fleet was running a DIFFERENT binary under the same version
name -- the proof deploy was a hand build, the release is -trimpath.
Self-update could never have corrected it, because every version check
compares the string. Both boxes reinstalled from the downloaded package.
The proper fix exists in miniature as wrapper_sha256 and was never
extended to the agent's own binary.

R-350: I printed the hub password into the session transcript via
curl -w '%{redirect_url}' -- the hub answers 303 and curl re-attaches the
credential. Not in git, not in any committed file, not in the evidence
directory. Rotation is the operator's call.

ep0 closes at fd 17, ESTAB 0, CLOSE-WAIT 0 -- its t0 baseline -- and was
read-only for this entire arc.
2026-08-20 12:54:54 +02:00

584 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPIKE — who holds ep0's connections open, and does the leak track the poll rate?
**Run:** 2026-08-20 08:01–08:35 UTC, read-only throughout. **Phase C (the one mutation) has NOT been run** — it is
held at STOP 1 for the operator's window decision.
**Evidence:** `evidence-ep0-established-connections-2026-08-20/`, one file per step.
**Supersedes the plan in** `SPIKE-ep0-established-connections.md` (issued 2026-08-18, never run).
> **Headline, and it is not the headline the spike expected.**
> The leak is **ours**. Every one of the 388 leaked descriptors on ep0 is an established connection held
> open by **`felhom-agent`** on the customer boxes — one per PBS poll cycle, never closed on either side.
> `pvestatd` and `proxmox-backup-client` made **162,404 requests** in the same window and leaked **zero**.
> R-336's premise — that cutting the ~85,000/day poll rate is the leak fix — **does not survive this
> measurement.** The poll rate is a separate (real) problem; it is not what is consuming descriptors.
---
## Q0 — R-341's first dated check: has the fd slope changed since the PBS 4.2.5-1 upgrade?
### Answer: NO. Unchanged, and now measured over a window 88× longer than the one it replaces.
**Precondition passed.** `MainPID = 551655`, `ps -o lstart` = `Tue Aug 18 09:51:04 2026`, `NRestarts = 0` on
both `proxmox-backup-proxy` and `proxmox-backup`. The proxy generation that carries `t0` is the one that was
read. The elapsed-window measurement is valid.
| | value |
|---|---|
| `t0` | fd **17**, ESTAB 0, 2026-08-18 **09:51:22Z**, PID 551655 |
| reading A | fd **405**, ESTAB **388**, CLOSE-WAIT **0**, 2026-08-20 **08:02:13Z**, PID 551655 |
| elapsed | **166,251 s** = 46.181 h = 1.9242 d |
| delta | **+388 fd** |
| rate | **201.6 fd/day** (8.40/h) |
| Poisson on n=388 | ±19.7 counts → **±10.2/day (1σ)**, ±20.5/day (2σ) → **181.2 … 222.1 /day** |
| limits | soft/hard **65536 / 65536** — the drop-in survived the upgrade |
| listener | `LISTEN Recv-Q 0 Send-Q 1024` — accept queue empty, nothing wedged |
**Verdict against the pre-registration**, quoted from the spike prompt §2 so it is visible beside the result:
> *"Predicted range: roughly **370–450** … A reading anywhere in **330–490** confirms an unchanged rate …
> **In range** → unchanged rate. This is the EXPECTED result, consistent with the changelog containing no
> mechanism to change it. It is not a failed upgrade."*
**Observed: 388.** In range, and close to the centre. The rate is **unchanged** since the upgrade. This is the
expected result and it is recorded as such — not as a success and not as a failure of the upgrade, which was
run for rehearsal value and never claimed to fix anything.
For comparison, on the identical method: **183/day** (31 min, pre-upgrade), **200/day** (5.64 h, pre-upgrade),
**225/day** (32 min, post-upgrade). This window carries **97× the descriptor count** of the 31-minute window,
so its uncertainty is ±5% rather than ±50%.
### Composition — the split, which is what says *which* leak it is
```
t0 fd 17 ESTAB 0 CLOSE-WAIT 0
reading A fd 405 ESTAB 388 CLOSE-WAIT 0 17 + 388 = 405, exactly
```
**`CLOSE-WAIT` is absent from the `ss` histogram entirely — 0, not merely flat.** 100% of the growth is
**established** connections on `:8007`. This closes the question the incident's own correction block opened:
the mechanism is *connections the proxy never reaps*, and `CLOSE-WAIT` is not merely the minority half, it is
**not present at all** on this proxy generation.
### Runway
From fd 405 at 201.6/day to the 65536 soft limit: **≈ 323 days**, i.e. around **2027-07-09**. Under a year.
A deadline, not a comfort — unchanged in character from what R-341 recorded.
---
## Q1 — who is at the far end?
### Answer: exactly the two demo boxes, in a dead-even split. No third peer. Outcome (c) is excluded.
At 08:02:39Z, `ss -tn state established '( sport = :8007 )'`, peer histogram:
| peer | connections | identity |
|---|---|---|
| `10.77.0.2` | **194** | `demo-felhom` (host `felhom-pve`) — token `felhom@pbs!demo-felhom` |
| `10.77.0.3` | **194** | `demo-hp` (host `felhom-host`) — token `felhom@pbs!demo-hp` |
The identity is not inferred from the address: ep0's own access log carries the API token name on every line
(`::ffff:10.77.0.3 - felhom@pbs!demo-hp …`), and the mapping is one-to-one across the whole file.
**194 / 194 is not approximately even — it is exactly even**, which is itself a finding: whatever opens these
is a per-box periodic task running the same schedule on both, not traffic proportional to anything the boxes
differ in.
### Descriptor-type breakdown (1.3)
`lsof -np 551655`, which distinguishes sockets from files, pipes and eventfds so a "leak" that is not sockets
gets caught here:
| type | count |
|---|---|
| **IPv6 (TCP)** | **390** |
| REG (log files, rrd journal, datastore lock) | 32 |
| unix | 7 |
| a_inode (epoll ×2, eventfd ×1) | 3 |
| DIR | 2 |
| CHR (`/dev/null`) | 1 |
`lsof … | grep -c TCP` = **390** = 388 established + 1 listener + 1 other. The independent `/proc/<pid>/fd`
link-target histogram agrees: **397 sockets** (390 TCP + 7 unix), 9 regular/anon fds. **The leak is sockets,
and the sockets are TCP on :8007.** Nothing else is growing.
### 1.4 — persistence: are they long-lived, or churn?
Full 4-tuples diffed between reading A (08:02:39Z) and reading B (08:33:42Z), **1,843 s apart**:
| | count |
|---|---|
| sockets in reading A | 388 |
| sockets in reading B | 392 |
| **in BOTH — long-lived** | **388** |
| **only in A — closed during the interval** | **0** |
| only in B — new arrivals | **4** (2 per box) |
**Not one socket closed in 31 minutes.** There is no churn to separate from the leak: every established
connection on this proxy is the leak. The 4 arrivals over 1,843 s = 187.5 fd/day, consistent with the
elapsed-window 201.6/day within Poisson on n=4.
**Idle age and liveness.** All 392 sockets carry a TCP keepalive timer and **all 392 report `retrans=0`** —
the far end is answering keepalive probes. These are not half-open sockets whose peer went away; they are
mutually held, live, idle connections. That, on its own, points at outcome **(a)** and Part 2 confirms it.
---
## Q2 — do the boxes hold them too?
### Answer: **outcome (a)** — both sides hold every connection, and the two counts match exactly. Twice.
| instant (UTC) | ep0 ESTAB on `:8007` | `demo-felhom` ESTAB to `10.77.0.1:8007` | `demo-hp` | sum |
|---|---|---|---|---|
| reading A — ep0 08:02:39, boxes 08:04:37/38 | **388** | **194** | **194** | **388** |
| reading B — ep0 08:33:42, boxes 08:34:01/02 | **392** | **196** | **196** | **392** |
Two independent paired readings, both exact. And the confirmation is tighter than the totals: the **four new
sockets** that appeared on ep0 during the persistence window carry **the same four source ports** the boxes
report as new —
```
ep0 new: 10.77.0.2:45620 10.77.0.2:53226 10.77.0.3:37554 10.77.0.3:46214
demo-felhom new: 10.77.0.2:45620 10.77.0.2:53226
demo-hp new: 10.77.0.3:37554 10.77.0.3:46214
```
Both boxes also closed **zero** sockets in the same interval (A=194, B=196, closed=0, new=2, on each).
**This is not a hedge between two outcomes.** Outcome (b) — half-open sockets ep0 never noticed — is
excluded by both the matching counts and the `retrans=0` keepalives. Outcome (c) is excluded by Q1.
### And the owner has a name
`ss -tnp` on the boxes attributes **194 of 194** on each, to a single PID:
| box | PID | process |
|---|---|---|
| `demo-felhom` | 2596329 | `/usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json` |
| `demo-hp` | 3199936 | `/usr/local/bin/felhom-agent --config /etc/felhom-agent/agent.json` |
**Zero are held by `pvestatd`. Zero by `proxmox-backup-client`.** Both agents have been running continuously
since 2026-08-12 (they did not restart); the count restarted at the proxy's 08-18 restart because that
restart closed the old sockets on both sides.
Each agent's own descriptor table is **208 fds, of which 196 are these connections** — so the leak is
bilateral. The agents' `RLIMIT_NOFILE` is 524287, so they are in no danger; that is a reason it stayed
invisible, not a reason it is harmless.
---
## The user-agent attribution (1.5) — the access log settles it independently
Over the same 46.18 h window, from ep0's own `access.log.1` + `access.log`:
| user agent | endpoint | requests | req/day | **descriptors leaked** |
|---|---|---|---|---|
| `libwww-perl/6.78` | `GET /admin/datastore` (pvestatd) | 81,192 | 42,195 | **0** |
| `proxmox-backup-client/1.0` | `GET …/felhom-offsite/status` | 80,061 | 41,607 | **0** |
| `libwww-perl/6.78` | `GET …/felhom-offsite/snapshots` | 1,131 | 588 | **0** |
| **`Go-http-client/1.1`** | **`GET …/felhom-offsite/snapshots`** | **387** | **201** | **388** |
| `Go-http-client/1.1` | `GET /version` | 371 | 193 | (same connections) |
| `Go-http-client/1.1` | `POST …/verify` + task status | 53 | 28 | (same connections) |
| | **total, both boxes** | **163,215** | **84,822** | **388** |
`Go-http-client/1.1` is `felhom-agent` — the same process `ss` names on the box side, reached by a completely
independent route. **387 agent `/snapshots` calls against 388 leaked sockets: one per call, within one.**
**162,404 requests from the two Proxmox pollers leaked nothing.** They are 99.5% of the traffic and 0% of the
leak.
### Cadence reconciliation — the numbers close
| source | cadence | cycles in 166,251 s |
|---|---|---|
| agent hub-report poll (`poll_seconds: 900`, read from each box's `agent.json`) | 15 min | 184.7 |
| `pbs.VerifyLoop` (`DefaultVerifyCadence = 6h`) | 6 h | 7.7 |
| | **sum** | **192.4** |
Observed leaked per box: **194**. Observed `POST …/verify` per box in the window: **8** (predicted 7.7).
Observed `GET /version` per box: ~185.5 (predicted 184.7). Mean interval between leaked descriptors per box:
**857 s**. Every count reconciles against a cadence read from configuration, not fitted to the data.
### Mechanism — named from source, and it is a two-line Go footgun
`felhom-agent/internal/pbs/client.go:56-60`:
```go
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{TLSClientConfig: tlsCfg},
},
```
A hand-rolled `http.Transport` takes the **zero value** for `IdleConnTimeout`, which in Go means *no limit* —
idle keep-alive connections are never closed. (`http.DefaultTransport` sets 90 s; a composite literal does
not inherit it.) That alone would be harmless if one client were reused. It is not:
`felhom-agent/cmd/felhom-agent/main.go:1486-1509` — `pbsTargetsFromPVE` — says so in its own doc comment:
> *"returns a `pbs.Targets` closure that, **each cycle**, discovers the pbs storages from the PVE config and
> **builds a fingerprint-pinned, token-authed client for each**"*
So each cycle constructs a **new** `Transport`, performs one request, and leaves the connection idle in that
transport's pool forever. The transport then becomes unreachable, but Go's `persistConn` read-loop goroutine
keeps the socket alive — an unreachable `http.Transport` does not close its connections. `grep` across the
whole `felhom-agent` repo for `CloseIdleConnections`, `IdleConnTimeout`, `MaxIdleConns`: **no matches at all.**
**The same composite-literal pattern appears in two more clients** — `internal/hub/client.go:53` and
`internal/proxmox/client.go:69`. Those are built once at start-up rather than per cycle, so they do not leak
by this route; they are recorded because the pattern is one refactor away from doing so. Filed as **R-344**.
---
## Control-window cleanliness (1.6)
The elapsed window is doing double duty as Phase C's control, so it has to be shown undisturbed. It was.
| check | finding |
|---|---|
| **ep0 host reboot** | none — booted 2026-08-03 11:12:57, up 17 d |
| **proxy / API daemon restart** | none — `NRestarts = 0` on both units, PID 551655 throughout |
| **box reboots** | none — both booted 2026-08-10 09:2xZ, up 10 d |
| **agent restarts** | none — both `ActiveEnterTimestamp` 2026-08-12 |
| **request-rate gaps** | **none.** Hourly totals across the window: 3,536–3,553 every hour, no hour missing |
| **HTTP errors** | **none.** Every response in the window is `200`, except 8 × `101` (expected protocol upgrades) |
| **tunnel flaps** | no evidence — WireGuard handshakes for `10.77.0.2`, `.3`, `.250` all seconds old; and a flap would have shown as a request gap or errors, and neither exists |
| **a backup inside the window?** | **NO.** The last offsite protocol upgrades were 2026-08-18 **03:57 and 03:58Z** — the incident re-runs, **before `t0` at 09:51:22Z**. Both boxes' `pvesm list felhom-pbs` confirms the newest snapshots are `2026-08-18T03:57:43Z` / `03:58:43Z`. The tier's `cadence_seconds` is **604800** (weekly), read from each box's `agent.json`, so the next offsite run is due ~2026-08-25 |
| **other non-poll activity** | **yes, and it is routine, quantified, and does not contaminate.** Server-side verify jobs ran on ep0 on a ~6 h cadence (8 per box in the window), and restore-test reads ran on 2026-08-19 at 04:45–04:50 and 06:52–06:54Z (8 reader upgrades total). Together with snapshot/version/task-status calls this is **~1,963 of 163,215 requests = 1.2%** of the window's traffic, and at most 8 of the 388 descriptors could be attributed to reader connections (≤2%). The verify cycle is already counted **inside** the 194/box reconciliation above — it is part of the measured leak, not a contaminant of it |
**Verdict: the control window is clean.** No correction is applied and none is needed.
**Two nuances recorded rather than smoothed over.**
1. `systemctl show proxmox-backup-proxy -p ActiveEnterTimestamp` reads **2026-08-18 03:54:54Z**, not 09:51:04Z,
while `NRestarts = 0` and `MainPID` did change. The upgrade therefore **re-exec'd** the proxy rather than
restarting the unit — systemd never saw a stop. Anyone using `ActiveEnterTimestamp` as the "when did this
proxy generation start" anchor would get a figure **5 h 56 m too early** and compute a rate ~12% low (388 fd over 52.1 h instead of 46.2 h = 178.7/day instead of 201.6/day).
Use `ps -o lstart= -p $MainPID`, as R-341's own command does.
2. ep0 carries a fourth WireGuard peer, `10.77.0.4`, whose last handshake was 2026-08-13 (7 d ago). It is
`drill-r50-0a4f9a`, a drill host, and its dormant peer entry is already recorded in
`architecture/_recovery-inventory-2026-07-28.md`. Outside the window; not a contaminant; not a new finding.
**Hub view.** No `pbsdr_box_unreachable` / `offsite_box_unreachable` and no `*_recovered` event was logged.
The positive observable rather than the absent one: the PBS-DR gauge refreshes on schedule
(`PBS-DR box refreshed: 3.7% full (3.7 GB of 97.9 GB)`, every ~16 min through the run) and host reports land
from both boxes. **Honest limit:** the hub pod is 39 h old (redeployed for v0.106.0 at 2026-08-18 19:29), so
hub-side log evidence covers **36.6 of the window's 46.2 hours**. For the first 9.6 h the evidence is ep0's
own access log — which shows no gap and no error — and `NRestarts = 0`. Hub log timestamps render **CEST**.
---
## Q3 — is the leak rate proportional to the request rate?
### NOT ANSWERED BY MEASUREMENT. Phase C has not been run; it is held at STOP 1.
What exists instead is a **pre-registered prediction**, written and committed before any Phase C number can
exist: `evidence-ep0-established-connections-2026-08-20/phaseC-prediction.txt`. Its substance:
- Stopping `pvestatd` on `demo-hp` removes **both** pvestatd-driven streams from that box (the PVE PBS storage
plugin issues the `libwww-perl` datastore list *and* shells out to `proxmox-backup-client` for the status
call, both on pvestatd's cycle) — **−98.8% of demo-hp's requests, −49.4% of the fleet total**. If ep0's
request count does not fall by close to half, the independent variable did not move and no ratio may be
computed.
- **H1 (what Parts 1–2 predict): NOT proportional.** demo-hp's `felhom-agent` keeps running, so both boxes
keep leaking. Expect **~201.6 fd/day, unchanged — ~84 descriptors in a 10 h window (2σ 66–102) — split
symmetrically ~42/~42 between the two peers.**
- **H2 (the hypothesis Q3 was written to test): proportional.** Expect **~101 fd/day — ~42 descriptors in
10 h (2σ 29–55) — split strongly asymmetrically ~42/~0.**
H1 and H2 do not overlap at 2σ for a window of 10 h or more. The **peer split is the sharpest discriminator**
and it is free: it needs no second box and no arithmetic.
**A result matching H2 falsifies the Parts 1–2 attribution**, and the prediction file says so in those words,
so the outcome cannot be rationalised in either direction afterwards.
---
## Which fix the evidence points at
**No design here, per the spike-first gate — only the target.** The evidence points at
`felhom-agent/internal/pbs/client.go`'s HTTP transport lifetime, not at the poll rate. One `pbs.Client` is
constructed per cycle by `pbsTargetsFromPVE`, each carrying a fresh `http.Transport` whose `IdleConnTimeout`
is the zero value, and nothing ever closes it; the observed leak is exactly one socket per agent
`/snapshots` call, on both sides, on both boxes, and it reconciles with cadences read from configuration. A
fix that touches only the ~85,000/day Proxmox poll rate would reduce ep0's request load by ~99.5% and its
descriptor leak by **zero**. R-336's poll rate remains a genuine problem — 85,000 requests/day to a
weekly-write DR endpoint is still wrong — but **it is a different problem from this one, and the row's
"cut the poll rate, then confirm the fd count stops climbing" plan would have produced a confusing null
result.** That is the substantive change to R-336 this spike delivers.
---
## What was inconclusive
- **Q3 is unmeasured**, by design — Phase C is held at STOP 1 for the operator. Everything above about
proportionality is a prediction, labelled as one.
- **The ~9.6 h of hub-side evidence before the v0.106.0 pod redeploy is gone** with the previous pod's logs.
The window is covered by ep0-side evidence for that period, which is adequate for the questions asked, but
it is not the same evidence and is not presented as such.
- **Why `demo-hp`'s and `demo-felhom`'s leaked counts are *identical* rather than merely similar** is not
established. Both run the same cadences, so equality is expected — but exact equality at two separate
instants (194/194 and 196/196) is stronger than the cadences alone require, and no attempt was made to
explain it beyond noting it.
- **Whether a reader (restore-test) connection also leaks** was not separated out. At most 8 of 388 sockets
could be involved (≤2%), which is inside the noise, so it was not pursued.
---
# Fix and proof — 2026-08-20, later the same day (R-344)
Run under `Claude Code Prompt: fix the agent's leaked PBS connections, then prove it on one box`,
which **cancelled Phase C**. Evidence: `evidence-agent-transport-leak-2026-08-20/`.
**Phase C was never run, and cancelling it was right.** It would have quietened `pvestatd` on `demo-hp`
to test whether the leak tracks the request rate. The spike had already shown `pvestatd` leaks nothing,
so the honest prediction was a null result. The experiment that replaced it — deploy the fix to one box
and leave the other as a control — proves the fix and re-proves the mechanism in one window, and it
opens with a free, immediate, decisive observation Phase C could not have produced.
## The fix
One field, restored to the standard library's own value. New leaf package `felhom-agent/internal/httpx`
owns `DefaultIdleConnTimeout = 90 * time.Second` — 90s because that is `http.DefaultTransport`'s value,
so there is nothing invented to justify — and `NewTransport(tlsCfg, idle)`, which returns a **fresh**
transport every call (never shared: each caller pins a different endpoint) and treats a zero or negative
timeout as **use the default, never "no timeout"**. Wired into all three hand-rolled transports;
`grep '&http.Transport{'` now matches only `httpx` itself.
`internal/hub/client.go` and `internal/proxmox/client.go` carried the identical missing default and were
corrected in the same pass. **Neither contributed to the ep0 leak** — both are built once per process,
so they held one idle connection for the life of the daemon rather than accumulating, and neither talks
to `ep0:8007`. This must not be read as three leaks having been found.
**A check made before trusting the fix:** `doBody` already reads the response body to completion and
closes it, so the connection genuinely reaches the idle pool. Had it not, `IdleConnTimeout` would have
been the wrong fix and the leak would have been an un-drained body instead.
### The tests, and the two red-proofs
`internal/pbs/client_leak_test.go` counts connections **server-side** and models what
`pbsTargetsFromPVE` actually does — build a client, use it once, drop it on the floor. It deliberately
does not assert `err == nil` and does not assert that a field holds a value; **both were true of the
leaking code**. This is the R-224 lesson applied: assert the consequence, not the mechanism.
| red-proof | mutation | result |
|---|---|---|
| 1 — the fix | remove `IdleConnTimeout` from `NewTransport` | **FAILS**: *"abandoned pbs.Clients: after 5s the server still holds 5 open connection(s), want 0 (5 dialled in total)"* — the count is in the message, so the failure cannot be a timeout with another cause |
| 2 — the worse fix | `DisableKeepAlives: true` | **the leak test PASSES.** Disabling keep-alive also stops the leak — by dialling fresh for every one of ~40,000 daily requests. `TestPBSClient_KeepAliveStillReuses` is what catches it: *"3 sequential requests over 3 connection(s), want 1"* |
Red-proof 2 is the load-bearing one: **Scenario A alone would have accepted a fix that made the problem
worse.** Both mutations were reverted and the tree re-verified clean.
Green gate: `go build ./... && go vet ./... && go test ./...` → 30 packages, 0 failures.
`python3 scripts/agent_gates.py` → all 4 gates OK.
## P1 — the restart observation
Predicted in advance as one of three outcomes. **Outcome (i), within one second.**
| | ep0 fd | ESTAB | from `demo-felhom` | from `demo-hp` | CLOSE-WAIT |
|---|---|---|---|---|---|
| T-0 `09:15:46Z` | 415 | 398 | 199 | **199** | 0 |
| T+1s `09:15:47Z` | **216** | **199** | 199 | **0** | **0** |
| T+60s `09:16:42Z` | 218 | 201 | 199 | 2 | 0 |
**Outcome (ii) did not occur and gets no register row.** Not one socket converted to `CLOSE-WAIT`: ep0
reaps on the peer's FIN correctly, so the 543 `CLOSE-WAIT` seen at the 2026-08-18 wedge has some other
explanation and is not evidence of a second defect on the protected machine. That is a finding in the
negative and it was worth the sixty seconds it cost.
**Ownership is now established by a third independent route.** Killing our process released exactly
`demo-hp`'s 199 descriptors — agreeing with `ss -tnp` on the box and with the access-log user agent.
**The new code retires connections, observed directly:** at 133 s uptime `demo-hp` held **0**
connections; the 2 it opened after restart were closed at the 90 s idle mark.
## P2 — the divergence window
**Window 09:15:47Z → 10:17:52Z = 1.03 h. The task specified ≥ 4 h; the operator closed it early.**
The conclusion below therefore does not rest on an extrapolated daily rate, and none is used.
| box | agent | start | end | delta | per hour |
|---|---|---|---|---|---|
| `demo-felhom` — CONTROL | 0.129.0 | 199 | 203 | **+4** | 3.87 |
| `demo-hp` — FIXED | 0.130.0 | 0 | 0 | **+0** | 0.00 |
Predicted: control ≈4/hour, fixed ≈0. **Control observed 3.87/hour.**
**The assumption-free statement, and the reason the short window still settles it.** ep0's access log
counts the poll cycles directly: in that window **each box made exactly 4 `GET .../snapshots` calls and
4 `GET /version` calls**. Same cadence, same work, the same four chances to leak.
> **control: 4 cycles → 4 leaked connections. fixed: 4 cycles → 0 leaked connections.**
**The positive observable, per standing rule 3.** A zero leak is equally consistent with "fixed" and
with "the agent stopped working". It is the former: the fixed box's four poll cycles are in ep0's log,
alongside the control's four. The rest of the two boxes' traffic is near-identical in the window —
`libwww-perl` 924 vs 926, `proxmox-backup-client` 898 vs 898 — so **the only difference between them is
the agent binary**.
Poisson alone would give P(0 leaks | old rate, λ=4) = **1.8%**, which is suggestive rather than
conclusive. It does not stand alone: P1 released 199 descriptors instantly, the mechanism is identified
at `file:line` and pinned by a red-proofed test, and the fixed box was directly observed returning to 0.
## P3 — the second box, and the accumulated leak clearing itself
`demo-felhom` — the former control — was upgraded at `10:18:56Z` on the operator's word.
| | ep0 fd | ESTAB | CLOSE-WAIT |
|---|---|---|---|
| T-0 `10:18:55Z` | 220 | 203 | 0 |
| **T+2s `10:18:56Z`** | **17** | **0** | **0** |
| T+65s `10:19:58Z` | 20 | 3 | 0 |
| settled, 10:30–10:35Z | **17–19** | 0–2 | 0 |
**fd 17 is precisely ep0's `t0` baseline** — fd 17, ESTAB 0, recorded at 2026-08-18 09:51:22Z. The proxy
sits at 17–19 and returns to 17 between poll cycles, which is the "healthy proxy near 20 fds" the
incident document named as the positive observable. **ep0 was read-only throughout and its proxy PID
never changed (551655).**
**This corrects a sentence written earlier the same day**, in the v0.130.0 CHANGELOG draft and in the
STOP 1 report: *"does not clear the 388 descriptors already stuck on ep0 — those persist until that
proxy restarts."* **Wrong, and measured wrong within the hour.** The descriptors were held on *both*
sides; closing either side ends them. Restarting the two agents released all of them. Nothing on the
protected machine had to be touched, and nothing was.
## Fleet sanity
- Hub reports `demo-hp` **0.130.0** and `demo-felhom` **0.130.0**; 0.130.0 is above the golden's
MinAgent of 0.129.0, and **no `floor held` line appeared** for either box.
- **No `pbsdr_box_unreachable` / `offsite_box_unreachable` event** fired during any window.
- Positive observable rather than the absent one: the hub's PBS-DR gauge kept refreshing on schedule
(`3.7% full (3.7 GB of 97.9 GB)`) and host reports kept landing from both boxes throughout.
## An unrelated finding the deploy exposed — R-348
The **first two host reports after an agent restart carry `0 backups`**, while the box's own
`pvesm list` shows backups present on both tiers. `internal/backup/store.go`'s `Store` is in-memory and
its `byTarget` map is repopulated only when a backup **runs** — daily for the local tier, weekly for
offsite — so the field reads 0 for up to ~18 h after any restart. `restore_tests` did **not** blank,
because that half has a durable on-disk companion (`RestoreTestState`, R-189).
**It blinds no alarm, and that was checked rather than assumed.** `hub/internal/monitor/deadline.go`
already scans back over stored reports with a 7-day `backupEvidenceLookback`, whose comment names this
exact case — *"when the LATEST report carries none... and against an agent that stayed restarted for
days"* — and `pbs_snapshots` stayed populated at 2 regardless. So this is an observability wart, not a
safety hole.
**But the `Store` comment is misleading in a way this project has a rule about.** It reads *"Backups are
unaffected — their freshness has a ground truth on the storage (R-84)"*. That is true of the
**consequence** and false of the **field**, and a future reader may take it as a guarantee the field
stays populated. Filed as R-348.
## What this run did NOT do
- **Did not publish.** No Gitea package, no tag, no `artifact_agent_version` / `artifact_min_agent` /
`artifact_golden_version` / `min_controller_version` change, no staged self-update. **A box installed
from the current image still carries the leaking agent** — filed as **R-347**, and the CHANGELOG
heading stays `## UNRELEASED` until that decision is taken.
- **Did not reduce the poll rate.** R-336 stays open, **re-scoped**: it was never the cause of this leak.
- **Did not refactor `pbsTargetsFromPVE`** to cache or reuse clients, and added no
`CloseIdleConnections` call. See Observations.
- **Did not touch ep0** — no restart, no config, no package, no nftables. Reads only.
- Did not trigger a backup, restore or verify to generate traffic; did not contact `peti-felhom`.
## Observations
- **The closure refactor is not worth doing, and the measurement is why.** With the idle timeout
restored, an abandoned client's connection is gone in 90 s, so the standing population is bounded at
roughly one connection per box rather than growing without limit. Caching clients would add
cache-invalidation questions — a storage's fingerprint, token or namespace can change under it — for
no observable gain. Recommend leaving it.
- **Three other `http.Transport` defaults are still missing** and were deliberately left alone:
`MaxIdleConns` (0 = unlimited; `DefaultTransport` uses 100), `TLSHandshakeTimeout` (0 = no limit;
`DefaultTransport` uses 10 s) and `ExpectContinueTimeout`. None of them accumulates anything, and every
client bounds its whole request with `http.Client.Timeout`, so none is a leak. `TLSHandshakeTimeout`
is the only one with a plausible failure mode — a stalled handshake over the tunnel, bounded today
only by the outer 30 s client timeout. **Not changed here, because widening the diff would have made
this measurement unattributable.** Worth a look on its own terms; not filed as a defect.
- **A measurement error of mine, recorded because it nearly cost four hours.** The first P2 sampler
reported both per-box columns as 0 while the totals were right: `ss` prints
`[::ffff:10.77.0.2]:port`, and the pattern expected `10.77.0.2:`. Caught 15 minutes in by noticing
that a 0/0 split could not sum to 199. Fixed, then **one sample was proved by hand before committing
the window to it** — the check that should have happened first.
---
# Published — 2026-08-20, on the operator's word (R-347 CLOSED)
Released through `scripts/release-agent.sh 0.130.0`, the one documented way (R-115), which builds,
tags, publishes and **verifies by independent download** rather than by its own say-so.
| | |
|---|---|
| version / tag | **0.130.0** / `v0.130.0` at `7569f34` |
| sha256 | **`a56a92a7bd68f5b46736eaec4806c3d26c16ccb35118c4ac0e3d8094eaefabc3`** |
| size | 14,141,158 bytes |
| reproducible | **yes, checked** — a rebuild with `-trimpath -buildvcs=false` matches byte for byte (R-186) |
| anonymous fetch | matches the vouched sha |
## The vouch, and what was deliberately NOT changed
Day-0 artifact manifest: `agent_version` 0.129.0 → **0.130.0**, `agent_sha256` updated. **Everything
else re-sent unchanged**, and each for a reason:
- **`min_agent` stays 0.129.0.** It expresses what the **golden controller** requires, not what the
newest agent is. Raising it to 0.130.0 would have made the hub **HOLD the controller floor** for
every box not yet on 0.130.0 — the exact opposite of shipping a fix, and the manifest's own help
text says so: *"The hub HOLDS the floor for any box whose agent is below this."*
- **`golden_version` / `golden_sha256` / `wrapper_sha256`** — untouched; no controller was released.
- **The global floor was never touched** (still 0.216.0). **And on hub v0.106.0 it could not have been
by accident:** it is a *separate form with its own action* (`/configuration/global-floor`), so
publish-train rule 2's "the manifest screen carries the live DB floor — save it last" hazard no
longer exists in the shape its incident describes. Worth knowing before the next train; the rule's
reasoning still holds, its mechanism has moved.
Verified after: no `floor held` line for either box, no `*_unreachable`, both boxes reporting 0.130.0.
**No `--no-verify` anywhere in the train.** The CHANGELOG heading was flipped from
`## UNRELEASED — v0.130.0 candidate` to `## v0.130.0` **only after** the tag and package existed, so
`release-complete` passes on the real artifact. The ordering was: release at the UNRELEASED-heading
commit, then flip. The alternative — flipping first and bypassing the gate — would have produced a
genuinely red CI run and an alarm mail for a release that worked, which is R-168's failure mode.
## The trap this train exposed — R-349
**Both boxes were running a different binary under the same version name, and nothing would ever have
noticed.** The proof deploy used a hand build (`go build -ldflags …`); the release builds with
`-trimpath -buildvcs=false` for reproducibility. Same source, same version string, **different bytes**:
`256e0829…` on the boxes against `a56a92a7…` published.
The sharp edge is that **self-update cannot correct it**: the boxes already reported `0.130.0`, so the
vouched version looked installed and nothing would have happened, indefinitely. Every version check in
the system — the hub, `--version`, the artifact manifest — compares the version **string**, so the
divergence is invisible to all of them.
Corrected by installing the **downloaded** artifact (not a local rebuild — the boxes get the bytes a
fresh install would get) on both. Both now report `a56a92a7…`.
**The proper fix already exists in miniature:** `wrapper_sha256` makes exactly this drift visible for
the PBS wrapper — *"agents report the installed file's hash and a mismatch is surfaced on the host
page"*. It was simply never extended to the agent's own binary. R-349.
## A mistake of mine in this train — R-350
Confirming the vouch used `curl -w '%{redirect_url}'`. The hub answers the POST with a **303**, and
curl renders the redirect target **with the basic-auth credentials re-attached** — so the hub operator
password was printed in cleartext into the session transcript.
It is **not** in git, not in any committed file (checked by content, not by assumption), and not in
this evidence directory; it is in the Claude Code transcript on DooPlex. Every other call in the
session printed only the password's length — this arrived through curl's output formatting, which is
why the usual discipline missed it. Rotation is recommended and is the operator's call; the reusable
half is that **`%{redirect_url}`, `-v` and `--libcurl` all re-render a basic-auth credential** —
confirm a redirect with `%{http_code}` and read the flash from a follow-up GET.
## Closing state
```
ep0 10:52:26Z pid=551655 fd=17 estab=0 ctrl(.2)=0 fix(.3)=0 CLOSE-WAIT=0
```
**fd 17 is ep0's `t0` baseline**, and it returns there between poll cycles. The proxy is the same
process that has run since 2026-08-18 09:51:04 — **ep0 was read-only for this entire arc**, from the
spike through the fix to the release, and was never restarted, reconfigured or upgraded by any of it.