Skip to content

relay: the outbound frame queue is unbounded, so a slow client becomes relay memory #952

Closed

Description

@chappie-daemon

sink.rs: FrameSink::channel() is mpsc::unbounded_channel(). A stored read of thousands of large events queues every encoded frame as fast as it can decode them while the connection task drains at socket speed. Nothing bounds queued bytes and nothing pauses the producer; once a socket write blocks, the connection loop also stops checking liveness while the spawned read keeps filling the queue.

The one defect in this set with no bound anywhere in the design.

Fix: bound queued bytes and choose a full-queue policy — pause the read, or disconnect a client that cannot keep up — and decide the liveness check while a write is blocked at the same time.

Evidence: #948.

Activity

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

    @chappie-daemon
    CollaboratorAuthor

    Recon on job 010 measured both sides, and the answer changes what this issue is: the capture is unbounded too. It is an addition to both, not a restoration of anything.

    Capture: sendMessage is client.send(JSON.stringify(message)) behind a readyState !== OPEN guard and nothing else (src/web-socket-adapter.ts:150-155); bufferedAmount and highWaterMark appear nowhere in src/. The terminal stage of the stored read is a PassThrough whose write calls writeFn(chunk) and returns callback(null) synchronously (src/utils/stream.ts:11-18). Fed 200,000 chunks with nothing reading the destination: produced=200000 written=200000 paused=0, rss 67.5 MiB — no backpressure anywhere in the pipeline.

    Rust: unbounded on both sides, and the Rust-specific half is that the liveness check runs in the same loop that writes (crates/nostream-relay/src/sink.rs:37-45), so a slow client starves liveness rather than merely buffering.

    Two policies, one decision

    Recon recorded both and left the choice to the spec, because they are not interchangeable:

    • Bound the queue and pause the producer — bounded memory, but the connection loop must keep checking liveness while a read is paused, which is the loop this defect is about.
    • Disconnect the slow client at a generous bound — the liveness check stays where it is, and the bound becomes a policy about clients rather than about memory.

    The capture's behaviour is "close on a missed pong" (already implemented), which is closer to the second. The spec must pick one and say why the other was rejected; a bound with no stated policy is the same class of decision this rewrite keeps having to defer.

  3. chappie-daemon commented on Oct 24, 2026

    @chappie-daemon
    CollaboratorAuthor

    Build started — factory job 010-relay-concurrency-fixes (spec stage done, builder running on factory/010-relay-concurrency-fixes).

    Requirements for this issue (R10–R13): FrameSink::channel(bound_bytes) counts queued bytes, send admits only under the bound (or into an empty queue), overflow drops the frame, sets a flag and logs once, and run_connection closes an overflowing client with 1013 and "slow consumer: outbound frame queue full". Every write in run_connection and drain_connection is bounded by a new write_timeout (30 s default), which is shorter than the 120 s liveness interval, so a blocked write can never park the loop past it. Bounds are RelayConfig knobs (frame_queue_bytes 8 MiB by default), not settings: the capture has no counterpart.

  4. chappie-daemon commented on Oct 24, 2026

    @chappie-daemon
    CollaboratorAuthor

    Landed in #962 (merge commit bca0f6f) on rewrite/main.

    Cost — job 010, actual vs expected: $2.34 actual against $250 expected (0.9%). Measured by factory-spans.ts over 17 sessions, 802 requests, 6.0 h summed wall, 3.3M in / 0.7M out / 85.2M cache-read. The job fixed all three defects in one branch; pro-rata by changed source lines this issue's share is about $1.05 (bounded outbound queue: sink.rs 229 + relay.rs 127 + transport.rs 4, plus the transport test), which is an attribution and not a separate bill — the job is the billing unit.

    The estimate was the defect, not the work: the model is being replaced with one built from measured jobs (see .factory/estimates.md).

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

    bugSomething isn't workingfactoryBuilt 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