Repository navigation
fix(relay): wait for the peer's Close before dropping any WebSocket - #8426
Conversation
The relay dropped TCP right after writing its terminal Close. The client's Close reply then hit a closed socket, the kernel answered with RST, and the client could lose the unread NOTICE and 1008 Close, surfacing as 1006. The connection now holds the read half until the client answers Close, bounded by the existing terminal deadline, and logs terminal frame send failures. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The new module matched no exact-name filter, so CI never ran it. The raw client now walks frame headers instead of scanning for a 0x88 byte. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Registry, presence and subscriptions no longer lag a nonresponsive peer by the close-handshake wait. Tests now bound the wait from above and cover a client that answers Close. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
🔐 Codex Security Review
|
Time from before connect so the hold lower bound and prompt upper bound cannot be consumed by scheduler delay; replace per-read 3s timeouts with one generous watchdog. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The client withholds its Close reply until the connection deregisters, so a relay that cleans up after the peer wait now fails deterministically. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Holding the read half through presence cleanup let a stalled Redis (the pool has no wait timeout) keep the socket open indefinitely. Running both concurrently caps the socket at the 1s peer wait while cleanup still never waits on the client. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
|
🤖 Addressed in a22ea8b. Connection cleanup (subscription release, deregistration, presence clear) now runs concurrently with the bounded wait for the client's Close via |
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The bootstrap drains on the relay and audio routes, and every audio handler exit, dropped the socket right after the server's Close. Unread client input then turns the close into a reset that discards frames the client has not read. Each path now holds the read half for the bounded Close wait after its cleanup finishes. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Exits that send their terminal frames and then await Postgres, Redis or the remote owner kept the socket open for as long as that cleanup took. Those exits and the normal teardown now hand the socket to a task that waits, bounded by WS_TERMINAL_FLUSH_TIMEOUT, for the client's Close. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… writer exit A relay socket refused during bootstrap never held a connection slot, and the close-delivery wait now keeps it open up to one more second, so refusal bursts could exceed BUZZ_MAX_CONNECTIONS. The slot is now taken before bootstrap and held through the refusal drain. The peer wait also started only after the expiry task join, which blocks on its quiescence barrier while a handler holds an effect permit across Redis with no deadline. It now starts as soon as the writer finishes. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ose-delivery * origin/main: feat(relay): resolve NIP-FI issuer bindings from the database (#8377) test(mobile): remove flaky channel collection assertion (#8440) fix(ios): supply missing push notification sound at handoff (#8443) feat(release): support version-matched mobile stable branches (#8430) fix(mobile): default community push notifications to on (#8425) fix(push-gateway): request default notification sound (#8431) feat(mobile): add swipe-to-reply message gesture (#8436) chore(deny): check buzz-mesh-smoke in the root graph again (#8424) fix(mobile): bump Flutter 3.47, resolving memory leak (#8419) fix(mobile): bound search result rendering work (#8418) Polish mobile message layout and navigation controls (#8427) feat(relay): start a channel with its members in one create (#8421) fix(desktop): bump zip to 9 for RUSTSEC-2026-0367/0368/0369 (#8412) fix(deps): bump negentropy to 0.5.2 for RUSTSEC-2026-0365 (#8409) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
A Nostr CLOSE handled inline could wait forever on the subscription lock behind a REQ stuck in Redis, so recv_loop never returned and the bounded peer wait never started. On cancellation the in-flight handler is now spawned to finish and joined before the expiry barrier and cleanup. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Audio admission errors now send Close 1008 (refusal) or 1011 (server failure), the peer wait drains to end-of-stream so a client-initiated Close gets its reply, both bootstrap drains share one bounded helper, and the audio read half becomes an enum so no expect() can panic. Adds a falsifying assertion for the in-flight join and a PostgreSQL test for a stalled archived-channel exit. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
These refusals sent only error text, so a silent client waited out the peer wait and was dropped without a Close. Draining closes 1012 so clients reconnect; the owner-resolve failure closes 1011; an unsupported deployment closes 1008. The shutdown comment now states per-stage budgets instead of a per-socket total the code does not guarantee. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
* origin/main: fix(relay): wait for the peer's Close before dropping any WebSocket (#8426) fix(desktop): release the live tail after a single scroll back to bottom (#8465) Signed-off-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz> # Conflicts: # crates/buzz-relay/src/connection.rs
…gent-admission * origin/main: feat(relay): tag every membership notice with its channel type (#8406) fix(relay): wait for the peer's Close before dropping any WebSocket (#8426) fix(desktop): release the live tail after a single scroll back to bottom (#8465) test(auth): read the clock once in NIP-FI until test (#8457) fix(mobile): reset community notification preferences (#8455) fix(release): keep mobile candidates pinned as branches advance (#8454) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
🤖 A NIP-42 login refused for a NIP-FI key mismatch could reach the client as an abnormal close (
1006, no reason) instead of theNOTICE "restricted: authorization denied"followed by close1008that every other authorization denial produces.The relay did write the NOTICE and the
1008Close, but the connection task dropped TCP as soon as the writer finished, before the peer finished the close handshake. The client could then lose frames it hadn't read yet. It depends on timing, so it showed up intermittently and is not specific to key pairing.Relay route
recv_loopnow hands back the read half even while a frame handler is still running. Frames are handled one at a time while the connection is live. Once it is cancelled, an in-flight handler (for example a NostrCLOSEwaiting on the subscription lock behind a REQ stuck in Redis) is spawned to finish instead of holding the reader. It is never dropped partway, so subscription retirement stays atomic, and cleanup joins it before the expiry barrier and application cleanup.await_client_closekeeps the transport open until the client's Close arrives or the stream ends. It runs concurrently with joining the heartbeat, auth-timeout, expiry and auth-cancel tasks and the connection's application cleanup (subscriptions released, registry entry removed, presence cleared). Cleanup never waits on the peer, so a nonresponsive client cannot delay registry counts, presence orhas_admitted_connection. The socket never waits on cleanup, so a stalled Redis (the pool has no wait timeout) or an expiry join blocked on its quiescence barrier cannot hold the socket and its file descriptor open past the bounded wait. The active-connection gauge and the connection permit are released after both finish.BUZZ_MAX_CONNECTIONS) is now taken at the start ofhandle_connection, before the NIP-FI gate and the community-active bootstrap check, and held through the active handler or the refusal drain and its bounded wait. Previously a socket in bootstrap, or refused there, held no permit, so a burst of refusals could open sockets beyond the limit. Over the limit, the socket is still dropped with no frames.buzz_ws_connections_activestill counts only connections that reached the active handler.Huddle-audio route
The relay and audio bootstrap drains share one bounded helper. A socket refused before admission waits after its community registry entry is released.
Every exit of the audio handler gets the bounded peer wait. Exits whose cleanup follows their terminal frames (
room_full,room_ended, version mismatch, archived channel, owner rejection or unreachable, failed commit confirmation) and the normal teardown hand the socket to a background task that waits for the client's Close, so lease releases,48102writes and room archiving on Postgres and Redis never hold the socket open. The remaining exits send their frames last, andhandle_active_audio_connectionwaits once they return. The wrapper joins the background task before returning, so the audio connection permit is held until the socket ends, as on the relay route.Audio error exits now follow their existing JSON error with a Close frame:
huddle_relay_draining1012service restartjoin_rejected(owner resolution failed),huddle_owner_unreachable, failed channel lookup1011internal errorhuddle_audio_unavailable,unsupported_version,upgrade_required,room_full,room_ended, archived channel, owner rejection, NIP-FI authorization denial1008policyAudio login failures and authorization refusals outside NIP-FI
authorization_deniedstill send only their existing error frame, then get the bounded wait.The audio read half is an enum, so the hand-off has no
expect()that can panic.Timing
WS_TERMINAL_FLUSH_TIMEOUT(1s), and drains to end-of-stream so a client-initiated Close gets its reply. The terminal writer flush has its own 1s budget, so the worst case for terminal transport work is about 2s per refused or closed socket.flush_terminal_frames,send_exit_frames_bounded) still give up on the first failure, but a failed or timed-out send is now logged atdebuginstead of being discarded.main.rsnow describes per-stage budgets (restart jitter, restart-Close acknowledgement, terminal flush, peer wait) rather than a per-socket total. The 30s hard drain bounds every socket, and the 35s process shutdown budget fits within the chart's 60sterminationGracePeriodSeconds.Tests
Real-socket tests in the infra-free relay lane of
just test-unitandscripts/run-tests.shunless marked PostgreSQL:1008/authorization deniedon the wire.handle_connection: a refused bootstrap holds the slot until its socket ends, and a bootstrap stalled on the community check already holds the slot before the AUTH challenge is sent.CLOSEhandler on a held subscription lock when the session expires: the socket ends within the bounded wait while the lock is held, and cleanup completes with the subscription retired once it is released.handle_audio_connectionwith a one-slot permit budget keeps the permit taken until that socket ends.huddle_audio_unavailablesends its error and then Close1008as the very next frame.