You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
relay: the 8 MiB frame bound is per socket and sockets are unlimited #959
The outbound queue is now bounded at 8 MiB per connection (RelayConfig::frame_queue_bytes, crates/nostream-relay/src/relay.rs:68), which bounds memory per socket. Nothing bounds the number of sockets: Relay::serve accepts every connection in a loop (relay.rs:166-180) with no semaphore or count, and the only rate limit is the per-IP attempt limiter, which throttles how fast connections are opened rather than how many are held.
So one source address that opens connections and then stops reading can hold N × 8 MiB, where N is however many sockets it got through the attempt limiter over time — the performance lens' measured figure is ~384 MiB from a single IP before any other client is considered. The per-socket bound turns an unbounded-per-socket growth into an amplifier: each held socket is now a predictable 8 MiB rather than an unbounded amount.
This is a policy question, not a defect in the bound, and it is outside #950–#952's diff. Options, none chosen:
A connection semaphore with a stated maximum, refusing or closing beyond it.
A per-IP concurrent-connection cap, separate from and in addition to the attempt rate.
A global frame-memory budget shared across sockets, so many slow clients share one bound rather than each getting 8 MiB.
The third is the only one that bounds memory by construction; the first two bound it by arithmetic on a configured number. Whichever is chosen, the constant needs to be a configurable knob with a default, and the record should say what happens to a client refused by it — the capture closes on a missed pong, and a refused connection should look like a refusal rather than a timeout.
Decision taken, so this can be built rather than held. Two numbers and one refusal shape, all configurable; the reasoning is what a reviewer should check.
Chosen: a global frame-memory budget, with a per-IP concurrent cap as the fairness layer
Option 3 bounds memory by construction — a shared budget cannot be multiplied by however many sockets a source opens, which is the whole defect. Options 1 and 2 alone bound it by arithmetic on a configured count, and the issue's own figure (~384 MiB from one IP) is exactly arithmetic that a second source doubles.
The per-IP cap stays as well, because a global budget under pressure makes the last client to arrive lose, and without a per-IP cap one source can be that client every time. It is not a memory bound; it is what keeps the budget's refusal from being aimed at whoever is unlucky.
knob
default
why this number
frame_memory_budget_bytes
256 MiB
32 sockets at the 8 MiB frame_queue_bytes ceiling can be fully buffered before the budget binds. Above this the process, not the policy, is the limit on a small VPS; below it, ordinary slow clients get refused while the box has memory to spare.
max_connections_per_ip
64
Four times the concurrent total a single well-behaved client needs (a browser tab pool, a few tools). The attempt limiter already throttles open rate, so this is the only control on sockets held.
refusal
close the socket immediately, code 1013, no text frame
The capture closes on a missed pong rather than explaining itself, and a frame the client will never read cannot be the way to say "you are not reading". 1013 because the condition is a server-side resource limit, not a protocol error.
Consequences a reviewer should hold me to
Both bounds refuse the same way, so a client cannot tell budget exhaustion from a per-IP cap. That is acceptable: neither is actionable by the client, and distinguishing them tells an attacker which limit to work around.
max_connections_per_ip counts live sockets, not attempts — it must not be folded into the existing attempt limiter, whose window is about rate. A single IP at 64 sockets is legitimate during a reconnect storm and must not be throttled as abuse.
The budget is per process, not per listener — a second bind on the same relay shares it.
Neither bound is the capture's. It has neither, so there is no parity to check; the ADR records them as deviations with the numbers, and a test asserts each refusal and that the budget is released when a socket closes (a leak here is worse than no bound).
A refused connection must not appear as a timeout in any metric: it has its own counter, because a timeout is indistinguishable from a network fault and this is a policy decision being executed.
This is a P2 configuration default in a knob, not a public API. It is chosen here rather than deferred because a held job blocks the queue and the alternative — an unbounded amplifier — is the defect the issue is about. Either number changes with a config edit and no code, and I will say so when the job lands.
Recon done; one fork settled by the repo's own rule, one follow-up filed.
The bounds land as RelayConfig fields, not settings — ADR 0005:104-110 records that port-only policy knobs are RelayConfig. The operator-facing YAML key is rewrite: expose the frame-memory budget and per-IP cap as settings, not only as RelayConfig #989, filed separately rather than folded into this job's bound. If that is the wrong call it is Cameri's to reverse and it reverses cheaply, because the accounting does not change.
The arithmetic matches the policy's justification: 256 MiB / 8 MiB per socket = 32 full sockets, which binds before both the 48-socket rate outcome and the 64-socket cap. The cap is not a memory bound; the budget is the one doing the work.
The accounting has no refund path today: release has exactly one caller (relay.rs:346) and every other loop exit — including the overflow close at :331-333 — leaves queued bytes counted forever. Per socket that is invisible; in a shared budget it is a leak, which is exactly why the policy demands a release test.
The refusal window is narrow and measured: it cannot precede transport::upgrade (no websocket to close) and cannot follow Connection::accept (relay.rs:294), because that call's first act sends the AUTH challenge as a text frame. It goes in the :245→:294 window, next to the rate check, or it is not frame-free.
Closed by 4672f7aa (PR #990), verified in the tree: git merge-base --is-ancestor 4672f7aa rewrite/main returns 0.
Closed by hand rather than by the PR's Closes keyword: GitHub only auto-closes on merge when the pull request targets the repository's default branch, and cameri/nostream's default is main while this PR targeted rewrite/main. The keyword was decoration and the issue stayed open after the merge.
Every factory job landing on the rewrite now closes its own issue with gh issue close <n> --reason completed and reads the state back as CLOSED, never trusting the PR body.
Activity
chappie-daemon commentedon Oct 25, 2026
Decision taken, so this can be built rather than held. Two numbers and one refusal shape, all configurable; the reasoning is what a reviewer should check.
Chosen: a global frame-memory budget, with a per-IP concurrent cap as the fairness layer
Option 3 bounds memory by construction — a shared budget cannot be multiplied by however many sockets a source opens, which is the whole defect. Options 1 and 2 alone bound it by arithmetic on a configured count, and the issue's own figure (~384 MiB from one IP) is exactly arithmetic that a second source doubles.
The per-IP cap stays as well, because a global budget under pressure makes the last client to arrive lose, and without a per-IP cap one source can be that client every time. It is not a memory bound; it is what keeps the budget's refusal from being aimed at whoever is unlucky.
frame_memory_budget_bytesframe_queue_bytesceiling can be fully buffered before the budget binds. Above this the process, not the policy, is the limit on a small VPS; below it, ordinary slow clients get refused while the box has memory to spare.max_connections_per_ip1013, no text frame1013because the condition is a server-side resource limit, not a protocol error.Consequences a reviewer should hold me to
max_connections_per_ipcounts live sockets, not attempts — it must not be folded into the existing attempt limiter, whose window is about rate. A single IP at 64 sockets is legitimate during a reconnect storm and must not be throttled as abuse.This is a P2 configuration default in a knob, not a public API. It is chosen here rather than deferred because a held job blocks the queue and the alternative — an unbounded amplifier — is the defect the issue is about. Either number changes with a config edit and no code, and I will say so when the job lands.
chappie-daemon commentedon Oct 25, 2026
Recon done; one fork settled by the repo's own rule, one follow-up filed.
RelayConfigfields, not settings — ADR 0005:104-110 records that port-only policy knobs areRelayConfig. The operator-facing YAML key is rewrite: expose the frame-memory budget and per-IP cap as settings, not only as RelayConfig #989, filed separately rather than folded into this job's bound. If that is the wrong call it is Cameri's to reverse and it reverses cheaply, because the accounting does not change.releasehas exactly one caller (relay.rs:346) and every other loop exit — including the overflow close at:331-333— leaves queued bytes counted forever. Per socket that is invisible; in a shared budget it is a leak, which is exactly why the policy demands a release test.transport::upgrade(no websocket to close) and cannot followConnection::accept(relay.rs:294), because that call's first act sends the AUTH challenge as a text frame. It goes in the:245→:294window, next to the rate check, or it is not frame-free.feat(relay): bound frame memory globally and connections per IP (#959) (
fix(config): reject a zero frame-memory budget or per-IP cap (#996) (#…
chappie-daemon commentedon Nov 1, 2026
Closed by
4672f7aa(PR #990), verified in the tree:git merge-base --is-ancestor 4672f7aa rewrite/mainreturns 0.Closed by hand rather than by the PR's
Closeskeyword: GitHub only auto-closes on merge when the pull request targets the repository's default branch, andcameri/nostream's default ismainwhile this PR targetedrewrite/main. The keyword was decoration and the issue stayed open after the merge.Every factory job landing on the rewrite now closes its own issue with
gh issue close <n> --reason completedand reads the state back asCLOSED, never trusting the PR body.