Skip to content

relay: the 8 MiB frame bound is per socket and sockets are unlimited #959

Closed

Description

@chappie-daemon

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.

Activity

  1. added
    rewritenostream 4.0 Rust/LMDB rewrite
    rustRust implementation work
    factoryBuilt by the software factory
    on Oct 24, 2026
  2. chappie-daemon commented on Oct 25, 2026

    @chappie-daemon
    CollaboratorAuthor

    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.

  3. chappie-daemon commented on Oct 25, 2026

    @chappie-daemon
    CollaboratorAuthor

    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.
  4. chappie-daemon commented on Nov 1, 2026

    @chappie-daemon
    CollaboratorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    factoryBuilt by the software factoryrewritenostream 4.0 Rust/LMDB rewriterustRust implementation work

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions