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.
34 KiB
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-agenton the customer boxes — one per PBS poll cycle, never closed on either side.pvestatdandproxmox-backup-clientmade 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:
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.Targetsclosure 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.
systemctl show proxmox-backup-proxy -p ActiveEnterTimestampreads 2026-08-18 03:54:54Z, not 09:51:04Z, whileNRestarts = 0andMainPIDdid change. The upgrade therefore re-exec'd the proxy rather than restarting the unit — systemd never saw a stop. Anyone usingActiveEnterTimestampas 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). Useps -o lstart= -p $MainPID, as R-341's own command does.- ep0 carries a fourth WireGuard peer,
10.77.0.4, whose last handshake was 2026-08-13 (7 d ago). It isdrill-r50-0a4f9a, a drill host, and its dormant peer entry is already recorded inarchitecture/_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
pvestatdondemo-hpremoves both pvestatd-driven streams from that box (the PVE PBS storage plugin issues thelibwww-perldatastore list and shells out toproxmox-backup-clientfor 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-agentkeeps 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 anddemo-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-hp0.130.0 anddemo-felhom0.130.0; 0.130.0 is above the golden's MinAgent of 0.129.0, and nofloor heldline appeared for either box. - No
pbsdr_box_unreachable/offsite_box_unreachableevent 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_versionchange, 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## UNRELEASEDuntil 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
pbsTargetsFromPVEto cache or reuse clients, and added noCloseIdleConnectionscall. 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.Transportdefaults are still missing and were deliberately left alone:MaxIdleConns(0 = unlimited;DefaultTransportuses 100),TLSHandshakeTimeout(0 = no limit;DefaultTransportuses 10 s) andExpectContinueTimeout. None of them accumulates anything, and every client bounds its whole request withhttp.Client.Timeout, so none is a leak.TLSHandshakeTimeoutis 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:
ssprints[::ffff:10.77.0.2]:port, and the pattern expected10.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_agentstays 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.