Repository navigation
fix(relay): read ownership, cancellation before the mint, and a bounded outbound queue (#950, #951, #952) - #962
Conversation
…n and consequence
🦋 Changeset detectedLatest commit: 11177d3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
[Medium impact] The changes affect connection behavior across the Rust relay's client sessions. The PR appears safe to merge, with a non-blocking timing problem in the new cancellation test. Findings
|
| client.send(r#"["CLOSE","invite"]"#).await; | ||
| tokio::time::sleep(Duration::from_millis(50)).await; | ||
| gate.add_permits(1); |
There was a problem hiding this comment.
Cancellation test can fail randomly
The test waits 50 ms after sending CLOSE, but nothing confirms that the server has handled it before gate.add_permits(1) releases the mint. If a busy runner delays the CLOSE handler, the mint can resume first and send stored events or EOSE. The test then fails even when the fix is correct. Wait for a server-side signal that abort_read has run before releasing the gate.
Three concurrency defects in
nostream-relay, found after the relay handlers landed in #947. Each was cut as its own issue and each was re-checked against the source before the fix.Closes #950
Closes #951
Closes #952
What was wrong
connection.rs.end_readremoved the in-flight entry by subscription id, unconditionally.begin_readaborts the read it replaces, so when the replaced read finished, its ownend_readremoved the replacement's cancellation sender. The replacement'swatch::Receiverthen saw a closed channel, took the cancellation arm, and returned withoutEOSE. Client-visible as: re-REQa subscription with new filters and it can go silent. This is new in the port — the capture has no per-id table and does not abort on a plain re-REQ.handlers/subscribe.rs.reqawaited the NIP-43 invite mint before any read was registered, so aCLOSEarriving in that window found nothing to abort: the handler then emitted the inviteEVENTand streamed the whole stored read toEOSEto a subscription the client had closed.sink.rs,relay.rs.FrameSink::channel()was an unbounded channel with no producer back-pressure, and its only consumer is the same select loop that performs the socket write. A stored read of large events could queue without limit while a client drained at link speed (recon measured 405 MiB for 200 000 queued frames), and once a write blocked, the loop could not poll the liveness tick at all.The change
Mutex<InFlightReads>: a connection-wide monotonicnext_generation: u64andby_subscription: HashMap<String, (u64, watch::Sender<bool>)>.begin_readreturns anInFlightReadhandle carrying the id, its generation and the receiver;end_readtakes it by value and removes the entry only while the stored generation still matches, so a replaced read's last act is a no-op.abort_read,read_in_flightandclosekeep their signatures and behaviour. One mutex as before, no allocation outside it, no.awaitinimpl Connection.await(relay: CLOSE during an invite mint is lost, and frames follow the close #951).begin_readmoves out offetch_and_sendand intohandle, ahead ofmaybe_issue_invite_code(..).await; the cancellation is checked beforefind_by_filters, so aCLOSEthat landed during the mint ends the subscription with no stored event and noEOSE. The inviteEVENTis deliberately still emitted — that is the capture's own behaviour (subscribe-message-handler.ts:111), and parity is the criterion here.FrameSinkcounts admitted bytes (compare_exchangeon a shared counter, so concurrent per-message tasks cannot lose an increment), admits a frame only while it fits or the queue is empty, and on overflow drops the frame, sets a sticky flag and logs once per racing sender.run_connectionreads the flag at the top of every iteration and closes the client with1013andslow consumer: outbound frame queue full, then falls through to the existing cleanup. Every write the connection task performs — queued frame, pong, ping, both error closes, each drainCLOSEDframe and the drain close — is wrapped intokio::time::timeout(write_timeout, ..), so no select arm can stay pending longer than that and the liveness tick is delayed by at most 30 s instead of unboundedly.RelayConfigknobs,frame_queue_bytes(8 MiB) andwrite_timeout(30 s), with the default timeout shorter than the 120 s liveness interval. They are not settings: the capture has no counterpart for either, so a settings key would mirror nothing —resources/default-settings.yamlandnostream-configare untouched.docs/rewrite/decisions/0005-connection-concurrency.mdrecords the three decisions, the alternatives rejected and why (including the pause-the-producer self-deadlock:dispatch_livesends from the loop that drains, so a permit wait there waits on the task that would release it), and states the port'sCLOSE-aborts-a-read semantics as out of scope for that record.Evidence
Oracle
cargo test -p nostream-relay --all-features: 133 passed, 0 failed at base 129 — lib 7→8,connection14→15,handlers49→50,transport13→14, everything else unchanged.Each deciding test was written and run against the unmodified sources first:
Err(RecvError(()))on the replacement's channel,in_flight=falseCLOSEduring the mintEVENT, storedEVENT,EOSE)EOSEE0061,E0599): no bound exists0x8frame in 9.96 s, last opcode10x8, code1013, the fixed reasonThe reworked disconnect test is falsifiable and was shown red for each of two mutations (bound raised out of reach; stored events removed) before being restored and shown green.
cargo test --workspace --all-featuresreports 0 failed on every target,cargo fmt --all --checkand bothCLAUDE.mdclippy passes exit 0, and no test was renamed,#[ignore]d or relaxed (ts_case_parity4,wire_parity5, no fixtures touched).Liveness while a write is blocked is the one criterion no command settles here: a loopback socket cannot be forced to block deterministically. It is recorded as judged on the diff (every write site wrapped, 30 s against the 120 s interval), not as measured.
Follow-ups filed separately
CLOSEand the capture does not; no decision record says so. Out of scope here, by ruling.CLOSEwhose handler task is scheduled ahead of itsREQstill aborts nothing (each client message is spawned independently andabort_readleaves no tombstone). Pre-existing; the fix above narrows the window but does not close it, and the ADR states that as a precondition rather than claiming it.Relay::serveaccepts without a cap; two message-decoding divergences the static analyser found innostream-core.