Skip to content

XERK-273: bound the hub's concurrent connections and its in-flight body bytes - #446

Merged
xerhab merged 12 commits into
mainfrom
XERK-273
Aug 13, 2026
Merged

XERK-273: bound the hub's concurrent connections and its in-flight body bytes#446
xerhab merged 12 commits into
mainfrom
XERK-273

Conversation

@xerhab

@xerhab xerhab commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes XERK-273 and XERK-258. At the deployed mem_limit: 256m the hub could be
OOM-killed by anyone holding the shared TURMA_AGENT_TOKEN, two different ways,
and neither bound covers the other's cost. The hub is the fleet's whole control
plane and restart: unless-stopped turns a crash into a repeating outage.

Measured on main, node:24-alpine at -m 256m:

shape result on main
1024 overlapping 0.9 MiB heartbeats OOMKilled, zero requests answered
2 concurrent 30 MiB heartbeats OOMKilled (XERK-258)

The ticket's diagnosis was wrong, which changed the work

XERK-273 attributed the first row to the socket count. Re-measuring says
otherwise: a socket costs ~28 KiB, so 1024 idle-bodied connections peak at
49 MiB and 4096 at 135 MiB. The bill in that row was the bodies. Both bounds
are needed and neither substitutes for the other — a connection cap safe against
a worst-case 32 MiB body would have to be ~4, and no byte budget can see a socket
that has not sent a body yet. That is why XERK-258 is folded in here rather than
left for later.

What this does

Connection cap (XERK-273). server.maxConnections, default 256, env
MAX_CONNECTIONS. Sized against real use (~72 sockets: agents' control channels
plus viewers' SSE, HTTP and terminal sockets), not against what survives. It
counts upgraded WebSockets, which is why it must clear steady-state use with room
to spare. Node destroys an over-cap socket before parsing, so a drop handler
logs the refusal, rate-limited.

In-flight body budget (XERK-258). Every memory ceiling is now a fraction of
the container's cgroup limit rather than a fixed number, logged at boot. The flat
128 MiB upload ceiling was double the whole container and could never fire before
the OOM killer did.

  • A body is charged BODY_PARSE_COST (3x) its wire size, not its wire size: the
    bill is the JS string plus the object graph JSON.parse builds beside it.
    Charging wire bytes admitted both 30 MiB beats and OOM'd anyway.
  • Two lanes — the shared budget, and one big body at a time that need not fit
    it. Without the big lane the hub's own advertised ceilings are unreachable
    under any concurrency: a real 65 MiB migration bundle was refused with 3 KB in
    flight, and HEARTBEAT_MAX promised a 32 MiB beat no concurrent moment
    accepted. The lane is re-judged per top-up, and a promoted body's charge moves
    lanes with it.
  • One ceiling across both lanes (BODY_INFLIGHT_TOTAL_MAX, MEMORY_LIMIT/2).
    Two independent ceilings have to be added to know the worst case and nobody
    does that arithmetic. BODY_INFLIGHT_MAX * BODY_PARSE_COST < BODY_INFLIGHT_TOTAL_MAX is load-bearing in both directions — the headroom
    above one max body is the room ordinary traffic runs in while the lane is held,
    and it is what keeps the worst case inside the container. Asserted in the suite.
  • Only bytes that ARRIVED are charged; a declared Content-Length is checked,
    never charged. An oversize body is deliberately NOT refused on its declaration,
    because refusing early makes Node close under a request still being written and
    python urllib — what hub-agent.py posts with — then loses its 413. That is
    XERK-235's offline loop.
  • A body holding budget must keep making progress (BODY_MIN_PROGRESS_BYTES
    per BODY_IDLE_TIMEOUT_MS, ~3 KiB/s), enforced only under contention, plus
    BIG_LANE_MAX_HOLD_MS as an orthogonal bound — no rate threshold separates a
    floor-rate attacker from a genuinely slow migration.
  • Refusals are 503 + {error}, never 413: the body was fine, the hub was
    momentarily full. Both readRawBody callers answered a flat 413 and now draw
    the distinction. On a 503 the migration relay holds the migration in
    exporting and _migration_upload retries 5xx (never 4xx) — nothing else
    would, and a lost bundle strands the move.

Verification

Handed to the qa agent, which returned FAIL seven times before this. Every
defect after the first came from one of my own fixes, and the unit suite never
once objected — all of them were caught by containerised adversarial
measurement. Notable ones, all now fixed and pinned by tests:

  • Charging the declared Content-Length let one silent socket — unauthenticated,
    via /api/login — wedge every POST route into 503 for 300s.
  • One legitimate 22 MiB heartbeat permanently leaked the whole shared budget,
    because a body promoted between lanes released into only one of them.
  • Billing the big body to the shared budget made merely holding the lane a
    total outage — a 200-byte heartbeat and the operator's own login refused,
    for ~29 kbit/s.
  • A dribble of one byte per window forged the liveness check the idle reclaim
    relied on.

Final QA verdict is PARTIAL: the row it was asked to clear survives 8/8 runs
where it OOM-killed 2 of 4 before, and the one remaining OOM is pre-existing
(reproduces on the prior commit) and filed as XERK-287.

Matrix at -m 256m, QA's harness (deliberately more overlapping than mine — its
figures are the ones to plan against), all OOMKilled=false with /healthz 200
afterwards:

row peak of 256 MiB
2 x 30 MiB 69 MiB
6 x 30 MiB 73 MiB
256 x 30 MiB 179-230 MiB over 8 runs
512 x 0.9 MiB 154 MiB
1024 x 0.9 MiB 160 MiB

Also verified: python urllib gets a readable 413 {"limit":33554432} at 32 and
33 MiB (XERK-235); the budget returns to 0 across all 12 framing paths, chunked
bodies, uploads, migration bundles and mixed traffic; keep-alive 5/5 with no
Connection: close; a real throttled 65 MiB migration completes.

Suites: 1107 node, 1289 python. Both new leak tests were run against a build with
the fix disabled and fail there, so they pin the defect rather than passing
beside it.

Residual, in QA's words

A single attacker socket paying the progress floor no longer denies anything.
Two concurrent large holders can briefly refuse all traffic, but the condition
clears itself within ~20s and sustaining it costs tens of Mbit/s — an ordinary
flood, not a cheap hold. Attachment uploads and ordinary traffic are unaffected.

Not fixed here — XERK-287

Held uploads are still a budget of their own outside the single ceiling, so
the true worst case is in-flight + uploads = 192 MiB of 256, and the flood row
OOMs once attachments are staged beside it. This change's own argument applies to
it, but closing it is a sizing decision with three user-visible levers (halve the
upload relay, halve HEARTBEAT_MAX, or raise mem_limit) rather than a code
fix, so it is filed with the measurements rather than decided here. XERK-287 also
carries the chunked-body variant, which additionally needs a Cloudflare tunnel
framing measurement this session could not take.

mem_limit stays at 256m per the ticket's direction; every number above is
headroom-bound, and the measurements are there so raising it stays an evidenced
decision.

Notes

No client-facing change: web and Android already read {error} off any non-2xx
generically (XERK-264), so the new 503 surfaces on both with no parity work.

xerhab added 12 commits August 12, 2026 14:41
…ody bytes

The hub is the fleet's whole control plane, every agent shares one
TURMA_AGENT_TOKEN, and `restart: unless-stopped` turns a crash into a
repeating outage. At the deployed `mem_limit: 256m` it could be OOM-killed
by anyone holding that token, two different ways, and neither fix bounds
the other's cost.

Measured on `main`, node:24-alpine at -m 256m:

  - 1024 overlapping 0.9 MiB heartbeats -> OOMKilled, zero requests answered.
  - 2 concurrent 30 MiB heartbeats -> OOMKilled (XERK-258).

XERK-273 attributed the first to the socket count. Re-measuring says
otherwise: a socket costs ~28 KiB, so 1024 idle-bodied connections peak at
49 MiB and 4096 at 135 MiB. The bill in that row was the bodies. Both
bounds are therefore needed — a connection cap safe against a worst-case
32 MiB body would have to be ~4, and no byte budget can see a socket that
has not sent a body yet.

Connection cap (XERK-273)

`server.maxConnections`, default 256, `MAX_CONNECTIONS` to override. Sized
against real use (~72 sockets: agents' control channels plus viewers' SSE,
HTTP and terminal sockets), not against what survives. It counts upgraded
WebSockets too, which is why it must clear steady-state use with room to
spare. Node destroys an over-cap socket before parsing, so a `drop` handler
logs the refusal — rate-limited, since a flood is thousands per second.

In-flight body budget (XERK-258)

Every memory ceiling is now a fraction of the container's cgroup limit
rather than a fixed number, logged at boot. The flat 128 MiB upload ceiling
was double the whole container and so could never fire before the OOM
killer did.

A body is charged BODY_PARSE_COST (3x) its wire size, not its wire size:
the bill is the JS string plus the object graph `JSON.parse` builds beside
it. Charging wire bytes was tried first and admitted both 30 MiB beats
against a 64 MiB budget, OOM-ing anyway. Raw Buffer bodies keep 1x —
nothing parses them.

A body arriving into an idle hub is always admitted, which is what keeps a
single 65 MiB migration bundle working; everything after it is admitted
only while the sum stays under budget. The per-request ceiling only ever
tightens, so a 64 GB host does not inherit an 8 GB single-body ceiling.

Refusals are 503 + {error}, never 413: the body was fine, the hub was
momentarily full. Both readRawBody callers answered a flat 413 and now draw
that distinction. On a 503 the migration relay holds the migration in
`exporting` and `_migration_upload` retries 5xx (never 4xx) — a bundle is
the largest body an agent sends, nothing else would retry it, and a lost
one strands the move.

Verified at -m 256m, all shapes surviving with real 200s served:

  1 x 30 MiB    52 MiB peak   1x200
  2 x 30 MiB    59 MiB peak   1x200, 1x503
  6 x 30 MiB    84 MiB peak   1x200, 5x503
  512 x 0.9MiB  114 MiB peak  23x200, 233x503, 256 refused
  1024 x 0.9MiB 71 MiB peak   23x200, 233x503, 768 refused

Ordinary beats succeed immediately afterwards, so nothing leaks the budget.
…re promised

QA found the first cut had traded one availability hole for a cheaper one,
and re-measuring the worst case found a second. Both are fixed here; the
OOM matrix still holds.

1. Reservation wedge (QA, HIGH)

Charging the DECLARED Content-Length reserved budget before any byte
arrived, so one socket that declared a 32 MiB body and then sent nothing
held more than the whole budget until the 300s request timeout — and every
other body on the hub was refused 503 meanwhile. It needed no bandwidth and
no credentials (/api/login reads its body before any auth gate) and was
renewable: a cheaper, longer fleet-wide outage than the OOM this exists to
prevent, and worst at the deployed 256m where one socket sufficed.

A declared length is a CLAIM. It is still CHECKED — against the per-request
cap, and against the budget — but never CHARGED, because a claim that is
never held is never denied to anyone else. Only buffered bytes are charged.

The idle-hub exemption had to become sticky per read (`sole`) to survive
that: judged per top-up, a lone 65 MiB migration bundle would sail past the
budget on its first chunk and then be refused mid-stream by its own charge.

Verified: 200 sockets each declaring 32 MiB and sending nothing, against
the real container — ordinary beats and an 8 MiB beat both still 200.

2. Refusal churn (found while re-measuring)

256 sockets (the connection cap) each sending a 30 MiB body still OOM-killed
the hub, with not one of those bodies buffered. Two causes, both about what
a REFUSED request costs:

  - Node hands us whatever accumulated in one `data` event — megabytes, not
    one buffer — so refusing on the first chunk still costs a chunk per
    socket. Hence the declared-length check above, which refuses before any
    of it arrives.
  - Node then DUMPS an unread body when the response finishes, to keep the
    connection alive: it resumes the stream we paused and reads all 30 MiB,
    discarding it. Discarded bytes are still read into memory. So a budget
    refusal now closes the connection (`endRefusedConnection`) after `finish`
    — the 503 is on the wire first, and the connection was not reusable
    anyway since we never read its body.

Draining is also rationed for budget refusals (BUDGET_DRAIN_SLACK, 64 KiB vs
1 MiB): it is a courtesy paid out of the resource that has just run out, and
unlike an oversize refusal it hits many requests at once.

Matrix at -m 256m, all surviving, hub serving afterwards:

  1 x 30 MiB     56 MiB peak   1x200
  2 x 30 MiB     59 MiB peak   1x200, 1x503
  6 x 30 MiB     58 MiB peak   1x200, 5x503
  256 x 30 MiB   56 MiB peak   1x200, 233x503     <- was OOMKilled
  512 x 0.9 MiB  119 MiB peak  194x200, 317x503
  1024 x 0.9 MiB 115 MiB peak  256x200, 237x503

Throughput went UP as a side effect: 256 served on the 1024 row against 23
before, because refused sockets are now released instead of drained.
…ize path

A second QA pass returned FAIL with three HIGH defects. All three are fixed;
one of them was a regression I had introduced against origin/main.

1. The ceilings were unreachable under any concurrency (QA)

The idle-hub exemption required the hub to hold EXACTLY zero bytes, so one
trickling request defeated it: a real 65 MiB migration bundle was refused
with 3 KB in flight, stranding the move, and HEARTBEAT_MAX went on
advertising a 32 MiB beat that — at 3x parse cost against a 64 MiB budget —
no concurrent moment would accept. The effective ceiling was TOTAL/3.

Replaced with two lanes (`bodyLaneFor`): the shared budget, and ONE big body
at a time that need not fit it. Worst case is one max-size body plus the
whole shared budget, which is what the container is sized against.

The lane is re-judged on every top-up, not latched. Latching it — which is
what I wrote first — removed incremental enforcement altogether: several
bodies entered the shared lane while small, never consulted it again, and
grew past the ceiling together. Two 30 MiB beats OOM-killed the hub with
the budget nominally in force. A body that outgrows the shared lane is now
promoted to the big one if free, else refused.

Verified: 8, 20 and 30 MiB beats all 200 with a trickle in flight.

2. The oversize/413 drain path OOM-killed the hub (QA, pre-existing on main)

Refusals on SIZE took none of the new protections, so the hub drained
cap + 1 MiB per socket and Node dumped the rest. 256 sockets streaming past
the cap killed it; so did the unauthenticated variant through /api/login.
QA confirmed origin/main dies the same way, so it is pre-existing — but it
is the same class this ticket exists to close and needs no credentials, so
it is fixed here rather than filed.

Draining is now capped by CONCURRENCY (DRAIN_CONCURRENCY_MAX), and past the
cap the refusal stops reading and closes. The count is the right unit: the
cost is concurrent read churn, not bytes, since draining keeps nothing.

3. Refusing oversize on the DECLARATION broke XERK-235 (my regression)

Refusing at header time is correct for the budget and wrong for size. It
makes Node close the connection under a request still being written, and a
client that writes its whole body before reading loses the response. That
client is python urllib — exactly what hub-agent.py posts with. Measured:
origin/main returns a readable 413 to urllib; my previous commit returned
`[Errno 32] Broken pipe` at every size tried. That is XERK-235's loop, where
an agent never learns its limit and re-sends the same body every beat.

An oversize body is therefore read to `cap` again, as on main. The budget
is what bounds it now: those bytes are charged like any other, so a flood is
refused 503 before buffering and only what the hub can afford ever buffers
its way to a 413. Restored and re-measured with real urllib: HTTP 413 with
its limit, and 256-socket oversize floods survive at 98-123 MiB.

Matrix at -m 256m, all surviving, hub serving afterwards:

  1 x 30 MiB       52 MiB    2 x 30 MiB      63 MiB
  6 x 30 MiB       78 MiB    256 x 30 MiB   118 MiB
  512 x 0.9 MiB   107 MiB    1024 x 0.9MiB   88 MiB
  256 x 33 MiB (oversize, authed)      98 MiB
  256 x 2 MiB  (oversize, unauth'd)   123 MiB
  200 sockets declaring 32 MiB, sending nothing: ordinary and 8 MiB beats 200
Third QA pass: all three of the previous HIGH defects verified fixed, and
the lane and drain bookkeeping came back clean under targeted attack (no
`bigLaneTaken` leak across 5 abort waves, no two bodies in the big lane, no
drain-slot leak across 5 waves x 24 oversize bodies, budget back to 0 on all
12 framing paths). One new HIGH, caused by this change, is fixed here.

The budget bounded how MUCH may be held. Nothing bounded how LONG.

One socket that streamed 22 MiB and then simply stopped charged past the
shared budget, took the big lane, and from then on every body on the hub was
refused 503 — tiny heartbeats and the operator's own login included — until
`requestTimeout` expired 300s later. Renewable at ~22 MiB per 5 minutes, or
about 0.6 kbit/s, which is a cheaper outage than the OOM this prevents.

A hold with no progress is not slow, it is abandoned, and the two are told
apart by whether bytes are still arriving. `BODY_IDLE_TIMEOUT_MS` (20s) is
armed only while a read actually holds a charge and is reset by every chunk,
so a genuinely slow client on a bad link never meets it — it keeps sending.
On expiry the charge is released, the lane freed and the socket destroyed;
the route writes nothing, since a caller that stopped mid-body is not
waiting to read a status.

Measured at 256m, one socket streaming 22 MiB then stalling:

  before        beat 200   login 401 (i.e. served)
  +1s .. +15s   beat 503   login 503     <- was the whole 300s, renewably
  +25s, +35s    beat 200   login 401     <- reclaimed, staller still attached
  after drop    beat 200   login 401

QA also measured the matrix under a more overlapping load pattern than mine
and got higher peaks (256 x 30 MiB: 200 MiB against my 118). The PR reports
QA's figures, not mine — the headroom is roughly half what I first claimed.

Two residual holes are FILED, not fixed, as XERK-287:

  - Chunked bodies have no declared length, so the cheap pre-check cannot
    fire and 256 of them plus held uploads still OOM at 256m. The fix is a
    concurrency cap on undeclared bodies, but the number is only safe if
    agent traffic really is declared-length at the origin — and every agent
    arrives through the Cloudflare tunnel, which this session cannot
    measure. Capping blind would refuse legitimate heartbeats fleet-wide.
  - The readable-413 window is 1 MiB wide, so urllib gets a status at 32 and
    33 MiB but not at 40. Identical on origin/main, so unchanged here.

Both are pre-existing shapes that origin/main handles worse (it OOMs at two
30 MiB beats), so neither blocks this change.
The idle reclaim did what it was built to do — QA confirmed a fully silent
hold is taken back at exactly the window, no progressing body is ever
touched, and a real throttled 65 MiB migration is unaffected — but it did
not close the wedge.

Resetting the window on ANY byte is not a liveness check, it is one an
attacker can forge. Warm up 22 MiB to take the big lane, then send one byte
every 15s: each byte tops up the charge and re-arms the timer, so the lane
is held indefinitely (QA proved it past 300s on a rig and to 75s on the
deployed container) while every POST on the hub — heartbeat ingest,
spawn/kill/rename, the migration relay, uploads and the operator's own login
— answers 503. Steady-state cost after the warmup is ~0.5 bit/s, three
orders of magnitude below what I claimed the previous commit had bought,
because renewing never has to re-stream.

The distinction the fix rested on was wrong, not its threshold: a dribble is
neither silence nor slowness. So the window now reopens on PROGRESS —
BODY_MIN_PROGRESS_BYTES (64 KiB) must arrive per window, making the rule a
minimum RATE of about 3 KiB/s. That is far below anything the fleet does
(agents reach the hub over a LAN or the tunnel) and far above what a dribble
can fake. QA's own finding stands behind the number: they could not
manufacture a >20s zero-byte gap from throttling, and a real slow migration
sits in the shared lane at megabytes per step.

The floor gives way to whatever the body has LEFT to send, so a
nearly-complete upload is never reclaimed over its last few bytes. An
attacker cannot use that: holding a big charge requires a large body, which
means a large remainder.

Verified at 256m against the dribble, with a legitimate slow sender running
alongside it the whole time:

  baseline    beat 200  login 401
  +5s         beat 503  login 503   <- one window, not 300s
  +25s        beat 200  login 401   <- reclaimed, attacker still dribbling
  +45s, +65s  beat 200  login 401
  after drop  beat 200  login 401

Re-confirmed unchanged: urllib gets its 413 at 32/33 MiB (XERK-235), and the
matrix still holds (2x30 52 MiB, 256x30 136 MiB, 1024x0.9 110 MiB, none
OOM-killed, hub serving after each).

Also labels XERK-287 with its repo, per the ticket-filing rule.
…tended

QA's fifth pass was cut short by a session limit, but it did settle one
thing: the remainder carve-out could NOT be stretched — a body engineered to
keep a small remainder was still reclaimed at 20.0s. Its rate-floor timing
was self-contaminated, so I re-ran those cases and found a false positive of
my own by reasoning about the arithmetic.

The progress floor applied to every charged read, however small. A 60 KB
request arriving at 1 KB/s has ~55 KB left, so the floor asks for 55 KB in a
window it cannot meet, and it is dropped — while holding 180 KB of a 64 MiB
budget. That protects nothing and breaks a legitimate call. Reclaiming is
for relieving contention, not for punishing slowness.

So it now fires only under pressure (`budgetUnderPressure`): the one big lane
occupied, or the shared budget more than half spent. Those are the states in
which one body holding on actually costs another its turn. With room to
spare nothing is reclaimed at all.

That gate does not soften the attacks, because both create the pressure they
need. Measured at 256m:

  Single-socket dribble (22 MiB warmup, 1 byte/15s), with a legitimate slow
  sender running alongside throughout:
    baseline 200/401; +5s 503/503; +25s, +45s, +65s all 200/401 with the
    attacker still dribbling; 200/401 after it drops.

  Multi-socket shared-lane dribble — the variant QA flagged but never built:
  64 sockets each warming 384 KB (too small for the big lane, together over
  the shared budget) then dribbling under the floor:
    baseline 200; 503 while warming; +25s, +45s, +65s all 200. The shared
    budget half-spent is itself the pressure, so the gate catches this shape
    without the big lane ever being involved.

  Dribbling AT the legal floor (64 KiB/window) is deliberately NOT reclaimed
  — it is paying the rate the rule asks. It costs ~35 kbit/s per socket,
  ~2 Mbit/s across 64, against ~0.5 bit/s before this rule existed. The hub
  stays available throughout; that is the budget degrading to 503s as
  designed rather than an outage.

Tests restructured around this: reclaim cases stage contention by occupying
the LANE (not the budget, which would refuse the body under test), and a new
case pins the false positive — an uncontended hub must not drop a slow
caller.
…y be held

QA's sixth pass corrected a factual error in my last report and found the
wedge still open. Both are addressed here.

The correction first: I reported floor-rate dribbling as "hub available
throughout". That measurement was the 64-socket SHARED case. The single
BIG-LANE socket is a total outage — QA measured the operator's own login at
503 across five windows — and my dribble probe missed it only because it
dribbled UNDER the floor, where reclaim does fire.

1. The lanes are now accounted separately

The big body's charge was going into the shared counter. Since a big-lane
body by definition exceeds the budget, merely OCCUPYING the lane refused
every other body on the hub: a 200-byte heartbeat, the operator's login,
everything. One authenticated socket could hold the fleet's control plane
offline for ~29 kbit/s, which is the whole outage.

Kept apart, a big body costs the shared lane nothing. Occupying the lane
delays other LARGE bodies — which is what an exclusive lane means — and
ordinary traffic never notices. The hub's ceiling is unchanged: one max-size
body plus the shared budget, which is what it is sized for. `pressure` is
judged per lane accordingly: shared reads by the shared budget, a big-lane
read always, since anything sitting in an exclusive lane blocks the next
body whatever room exists elsewhere.

Measured at 256m, one socket holding the lane and dribbling AT the floor:

  baseline        beat 200  login 401  25 MiB 200
  +20s..+80s      beat 200  login 401  25 MiB 503   <- was 503/503/503
  after it drops  beat 200  login 401  25 MiB 200

2. A wall-clock ceiling on lane occupancy

QA's read is right that no rate threshold closes the rest: a body dribbling
at the floor is byte-for-byte indistinguishable from a legitimate slow
migration at the same rate. So the bound is orthogonal — not "are you making
progress" but "you have had the lane long enough". BIG_LANE_MAX_HOLD_MS
defaults to 10 minutes, which a 65 MiB bundle clears at ~110 KiB/s, far
below what a LAN or the tunnel does, and the agent retries a reset anyway.

Verified with the ceiling wound to 30s against the same attack: 25 MiB
bodies go 503 at +20s and 200 from +40s on, while the attacker keeps paying
the floor rate.

Residual, stated plainly: an attacker paying ~29 kbit/s can still delay
LARGE bodies (migrations, history-heavy beats) for up to the ceiling, then
must re-establish. Ordinary traffic is unaffected throughout. That is a
bounded degradation of one feature rather than a control-plane outage.

QA's defect 2 (a legitimate migration stalling >20s past its halfway point
is reclaimed and reset, agent retries and re-ships) is not fixed here: with
the lanes separated it now costs other traffic nothing, and judging it needs
the same tunnel measurement XERK-287 already blocks on. Noted there.

Matrix unchanged: 2x30 67 MiB, 6x30 85 MiB, 256x30 110 MiB, 1024x0.9 91 MiB,
none OOM-killed, hub serving after each. Suites 1105 node / 1289 python.
QA's seventh pass found a BLOCKER in the previous commit, and it fires on
ordinary traffic with no attacker involved.

Separating the lanes was right, but a read can CHANGE lane: a large body is
admitted to the shared lane (its first chunks are small) and promoted when it
outgrows the budget. Its charge then sat in two counters while the read owned
one lane, and `release()` can only name the lane it ENDED in — so the whole
amount came off the big lane and the pre-promotion part was never given back.

One legitimate 22 MiB heartbeat therefore leaked the entire 64 MiB shared
budget, permanently. 21 MiB leaked nothing; 22 MiB leaked all of it, the
threshold being BODY_INFLIGHT_TOTAL_MAX / BODY_PARSE_COST = 21.33 MiB. Well
inside HEARTBEAT_MAX, and precisely the size XERK-235 exists because staged
history reaches. After it, every non-trivial body was refused for the life of
the process and `budgetUnderPressure("shared")` was stuck true, reinstating
the very false positive the pressure gate was added to prevent. This is the
"charge that outlives its buffer ratchets the budget shut" failure that
releaseBody's own comment warns about.

A charge must live entirely in the lane its read currently occupies, so
promotion now migrates it (`migrateToBigLane`) and release is correct by
construction.

QA's second finding has the same root cause and is closed by the same fix:
while a holder's pre-promotion charge stayed billed to shared, a holder
occupied nearly the whole shared budget, so my "ordinary traffic unaffected"
was wrong — a 0.5 MiB beat and a 4 MiB upload were refused too.

Measured at 256m, before and after one large beat:

  4 x 5 MiB concurrent, then a 21/22/30 MiB beat, then 4 x 5 MiB again:
    200,200,200,200 at every step (was 200,503,503,503 permanently)

  With a big-lane holder active, by body size:
    0.001 / 0.5 / 2 / 4 / 15 MiB -> 200      25 MiB -> 503
    all 200 again the moment it drops

Residual, corrected: an attacker paying ~29 kbit/s delays only bodies that
need the exclusive lane — migration bundles and heartbeats over ~21 MiB —
for up to BIG_LANE_MAX_HOLD_MS, then must re-establish, which QA showed takes
seconds. So the ceiling bounds any single hold, not aggregate denial of those
two things. Everything else is unaffected.

The two new tests were checked against a build with the fix disabled and both
fail there, so they pin the leak rather than merely passing beside it.

Matrix: 2x30 81 MiB, 6x30 97 MiB, 256x30 170 MiB, 1024x0.9 116 MiB, none
OOM-killed, hub serving after each. QA independently measured 256x30 at
203 MiB under a tighter pattern; that is the figure to plan against. Suites
1107 node / 1289 python.
QA's eighth pass confirmed the promotion leak is gone — no byte leaked
through any promotion path, across every abnormal exit, chunked bodies,
uploads, migration bundles and mixed concurrent traffic — but found that the
fix moved the memory ceiling, and 256 x 30 MiB now OOM-killed the container
in 2 of 4 runs. That row survived on the three previous commits.

The cause is structural, not a bad number. Separating the lanes was right,
but it left two INDEPENDENT ceilings whose sum is the real worst case, and
the shared half had been sized back when a big body still consumed it. So
the true bound quietly became `shared budget + a whole big body` with nobody
doing that arithmetic.

`BODY_INFLIGHT_TOTAL_MAX` is now the ceiling on everything in flight across
BOTH lanes (MEMORY_LIMIT/2), and shared admission counts the big lane. A big
body really is occupying memory; pretending otherwise is what let the worst
case drift. As it grows, ordinary traffic's room shrinks — but never to
nothing, because the ceiling sits above what any one body can charge. That
inequality is load-bearing and now asserted:

  BODY_INFLIGHT_MAX * BODY_PARSE_COST < BODY_INFLIGHT_TOTAL_MAX

It is what keeps a lane-holder from starving everyone (the total outage that
made the lanes separate accounts) AND what keeps the worst case inside the
container. At 256m: 96 MiB of units for one max body, 128 total, so 32 remain
for ordinary traffic while the lane is held. The hub logs it at boot as
"128 MiB across both lanes".

Effective admission in the killing flood drops from 160 units to 128.

Honest limit on my own verification: my flood harness does not reproduce
QA's OOM. On the old code QA measured 195-233 MiB with 2/4 OOM where mine
peaked at 170; on this commit mine peaks at 139-220 across 4 runs with no
OOM. That is consistent with an improvement and does NOT establish the row
is fixed — QA's harness is the instrument that detects this, and it has to
be the one to clear it.

If it does not clear, the next lever is lowering BODY_INFLIGHT_MAX, since
the inequality above leaves little room between 96 and 128. That would lower
HEARTBEAT_MAX from 32 MiB to 16 MiB, which is a user-visible change to the
largest beat a host may send, so it is a decision to raise rather than make
quietly.

Suites 1107 node / 1289 python. Lane tests restructured: staging now uses the
largest body that can actually exist rather than an impossible one, since
under a single ceiling a max-size body FITS the shared lane on a quiet hub —
the exclusive lane exists for when it does not.
…ERK-287)

QA's ninth pass confirmed the 256 x 30 MiB row is fixed on its harness (8/8
survive, where it OOM-killed 2 of 4 before), and found that held uploads are
still a budget of their own sitting OUTSIDE the single ceiling this work just
built. 128 + 64 = 192 MiB of a 256 MiB container.

That is this change's own argument turned back on it — two independent
ceilings have to be added to know the worst case, and nobody does that
arithmetic. It reproduces identically on the previous commit, so it is not a
regression, and closing it is a sizing decision with three user-visible
levers rather than a code fix. Filed on XERK-287 with the levers and the
measurements; recorded here so the next reader of these rules sees the whole
worst case rather than the half this file described.
main landed three changes that touch the same memory work, so each conflict
was resolved on what both sides were for rather than by picking a side.

agent/hub-agent.py — `_migration_upload`
  main gave it a True/False return so the caller can tell the hub instead of
  letting the move time out; this branch gave it a 5xx retry. Both kept: it
  retries a transient refusal and reports the outcome either way.

turma/server.js — the migration blob route (XERK-263)
  main now SPOOLS the bundle to disk instead of buffering it, and answers a
  uniform 404 so a refusal cannot name the source. Took main's side whole.
  The branch's 503/413 handling here is genuinely superseded: a spooled body
  never touches the in-flight heap budget, which is a better answer for this
  route than the budget was. Its comments citing the 65 MiB bundle as the
  reason the big lane exists are now stale for the relay and are corrected in
  the follow-up commit; the lane is still needed for large heartbeats.

turma/server.js — `containerMemoryLimit`
  Both sides added one, and function declarations hoist, so main's silently
  won and MEMORY_LIMIT would have been computed by the wrong implementation.
  Now ONE function, on main's contract (null when there is no limit to read),
  with the fallback to host RAM applied where this branch derives from it.

turma/server.js — the boot log
  Both print derived budgets for the same reason (they are derived, so being
  refused by them is otherwise the only way to learn them). Both kept.

CLAUDE.md — over the 40k CI gate after the merge
  Both sides added to Cross-cutting contracts and both belong there, which
  put the file at 42,235 characters. Split by path per the file's own rule
  rather than trimming rationale: the body-budget mechanics move to a new
  `.claude/rules/turma-limits.md` scoped to `turma/server.js`, and what
  genuinely spans components stays — the container-limit principle, the
  413/503 wire contract with the agent, the urllib caveat, and the XERK-287
  gap. 36,175 characters.

turma/tests/server.test.js — independent additions on both sides, both kept.
The trailing `});` was shared context closing whichever last test survived,
so this branch's final test needed its own.

Verified after merging: 1203 node tests, 1305 python, all green. At -m 256m
the boot log shows both features' derived ceilings, and the flood matrix
still survives with real 200s served — 2x30 19 MiB, 256x30 145 MiB,
1024x0.9 146 MiB, none OOM-killed, hub serving after each.
…ls (XERK-263)

The merge brought in XERK-263, which spools a migration bundle to disk
instead of buffering it. So the 65 MiB bundle no longer reaches the
in-flight heap budget at all, and citing it as the reason the exclusive lane
exists is now wrong — what the lane actually carries is large HEARTBEATS.

The bundle stays in the history of WHY the idle-hub rule was replaced, since
that is what the rule broke at the time, but it is marked as no longer the
rationale so a future reader does not restore it as one. Same correction to
BIG_LANE_MAX_HOLD_MS's sizing note, which was sized against the bundle.
@xerhab
xerhab merged commit 9cc1648 into main Aug 13, 2026
6 of 7 checks passed
@xerhab
xerhab deleted the XERK-273 branch August 13, 2026 00:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant