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 outbound frame queue is unbounded, so a slow client becomes relay memory #952
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.
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.
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.
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).
Activity
chappie-daemon commentedon Oct 24, 2026
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:
sendMessageisclient.send(JSON.stringify(message))behind areadyState !== OPENguard and nothing else (src/web-socket-adapter.ts:150-155);bufferedAmountandhighWaterMarkappear nowhere insrc/. The terminal stage of the stored read is aPassThroughwhosewritecallswriteFn(chunk)and returnscallback(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:
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.
chappie-daemon commentedon Oct 24, 2026
Build started — factory job
010-relay-concurrency-fixes(spec stage done, builder running onfactory/010-relay-concurrency-fixes).Requirements for this issue (R10–R13):
FrameSink::channel(bound_bytes)counts queued bytes,sendadmits only under the bound (or into an empty queue), overflow drops the frame, sets a flag and logs once, andrun_connectioncloses an overflowing client with1013and"slow consumer: outbound frame queue full". Every write inrun_connectionanddrain_connectionis bounded by a newwrite_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 areRelayConfigknobs (frame_queue_bytes8 MiB by default), not settings: the capture has no counterpart.fix(relay): read ownership, cancellation before the mint, and a bound…
chappie-daemon commentedon Oct 24, 2026
Landed in #962 (merge commit
bca0f6f) onrewrite/main.Cost — job 010, actual vs expected: $2.34 actual against $250 expected (0.9%). Measured by
factory-spans.tsover 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).