Files
buzz/scripts
Will Pfleger 91e7b469b8 fix(relay): wait for the peer's Close before dropping any WebSocket (#8426)
🤖 A NIP-42 login refused for a NIP-FI key mismatch could reach the
client as an abnormal close (`1006`, no reason) instead of the `NOTICE
"restricted: authorization denied"` followed by close `1008` that every
other authorization denial produces.

The relay did write the NOTICE and the `1008` Close, 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_loop` now 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 Nostr
`CLOSE` waiting 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.
- As soon as the writer finishes, `await_client_close` keeps 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 or `has_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.
- The connection permit (`BUZZ_MAX_CONNECTIONS`) is now taken at the
start of `handle_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_active` still 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,
`48102` writes and room archiving on Postgres and Redis never hold the
socket open. The remaining exits send their frames last, and
`handle_active_audio_connection` waits 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:

  | Exit | Close code |
  | --- | --- |
  | `huddle_relay_draining` | `1012` service restart |
| `join_rejected` (owner resolution failed), `huddle_owner_unreachable`,
failed channel lookup | `1011` internal error |
| `huddle_audio_unavailable`, `unsupported_version`, `upgrade_required`,
`room_full`, `room_ended`, archived channel, owner rejection, NIP-FI
authorization denial | `1008` policy |

Audio login failures and authorization refusals outside NIP-FI
`authorization_denied` still 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

- The peer wait is bounded by `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.
- Terminal frame writes (`flush_terminal_frames`,
`send_exit_frames_bounded`) still give up on the first failure, but a
failed or timed-out send is now logged at `debug` instead of being
discarded.
- The graceful-shutdown comment in `main.rs` now 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 60s `terminationGracePeriodSeconds`.

## Tests

Real-socket tests in the infra-free relay lane of `just test-unit` and
`scripts/run-tests.sh` unless marked PostgreSQL:

- The key-mismatch login and a session-expiry denial (as a control) both
assert NOTICE then `1008` / `authorization denied` on the wire.
- A raw-TCP client that never answers Close: the relay holds the socket
for at least the bounded wait, has deregistered the connection by the
time the socket ends, and ends it on its own under a 10s watchdog.
- A raw-TCP client that withholds its Close until the connection has
deregistered: the relay ends the socket in under the 1s cap, which fails
if cleanup waits on the peer.
- A raw-TCP client that never answers while final cleanup is stalled at
a test pause point: the socket still ends after the bounded wait.
- A raw-TCP client that never answers Close on the relay bootstrap
drain, the audio bootstrap drain and an early audio-handler exit: the
relay holds each socket for the bounded wait.
- On the relay route with a one-slot budget through the real
`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.
- An established relay session whose reader is blocked in a Nostr
`CLOSE` handler 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.
- An established relay session whose expiry task is stalled behind a
held session effect permit: the socket still ends within the bounded
wait.
- PostgreSQL: an established audio session whose teardown is stalled
after its Close, and a stalled archived-channel exit, both end within
the bounded wait from the hand-off.
- PostgreSQL: a fast refusal (unsupported protocol version) through the
real `handle_audio_connection` with a one-slot permit budget keeps the
permit taken until that socket ends.
- PostgreSQL: `huddle_audio_unavailable` sends its error and then Close
`1008` as the very next frame.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-19 16:13:27 -04:00
..