mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +00:00
## Summary Adds a durable, operator-controlled V1 for deleting an entire Buzz community without deleting another tenant's data. The workflow is exposed through `buzz-admin deletions`: - `sweep` records independent fleet storage-taxonomy observations - `submit`, `list`, `inspect`, and `approve` manage a deletion request - `unblock` resumes a fail-closed request after an operator records remediation identity and reason - `run` and `drain` execute bounded work Requests advance through a PostgreSQL-backed state machine and stop at `retention_pending` after logical deletion has been independently verified across PostgreSQL, object storage, and Redis. This PR ships the engine and CLI, not a continuously running worker or Kubernetes packaging. For V1, a cluster/VM administrator invokes `/usr/local/bin/buzz-admin` from the existing relay image, for example with `kubectl exec` or an equivalent container/VM exec path. ## What whole-community V1 removes For the target community, V1 removes: - rows from the allowlisted community-scoped PostgreSQL catalog, including members, profiles, authored events and bodies, DMs, reactions, mentions, memberships, tokens, workflows, moderation, audit, feedback, and rate-limit state - media sidecars and upload-attribution records under `_meta/<community>/` and `_uploads/<community>/` - Git repository pointers under `repos/<community>/` - Redis keys under `buzz:<community>:*` The community row survives as a permanent tombstone, and deletion control-plane records remain as evidence of the request, approval, execution, and result. ## Safety model Deletion is not a broad `DELETE CASCADE` followed by optimistic cleanup. The destructive boundaries are durable and fail closed. ### 1. Inventory and approval - `submit` resolves the target and freezes the schema plus summary-only storage inventory. - Approval is bound to the exact request, community, and frozen inventory digest. - Unsupported manifest versions, malformed keys inside the target's owned prefixes, live scoped-table/write-fence coverage drift, frozen-inventory mismatch, and approval mismatch block execution rather than guessing. Migration and catalog revision numbers are not authorization gates; the executor validates the live safety shape instead. - Storage inventory is server-side prefix scoped to exactly: - `_meta/<community>/` - `_uploads/<community>/` - `repos/<community>/` - The deletion path never lists the whole shared bucket and has no arbitrary per-community object cap. Its listing work is proportional to the target community's bindings, not total fleet storage. - Fleet-wide taxonomy sweeps remain independent observability. They report unknown writer shapes but do not gate deletion submission, fencing, or destructive progress. Maintainers must add deletion taxonomy coverage whenever a new community-owned object-key class is introduced; writer-coverage tests bind the current media and Git writers to that contract. ### 2. Quiesce, fence, and destructive freeze - Writes continue through submission, inventory, and approval. They stop when execution moves the target into `quiescing` and then establishes the durable fence. - Already-admitted external effects finish under heartbeated serving-write leases; the exact admitted lease may renew while the community is quiescing, but new lease acquisition is rejected. The executor drains admitted leases before destructive work. - Invite minting after quiescing begins fails as typed `AccessDenied` (HTTP 503 at the relay boundary) before an invite can be persisted. - Database triggers enforce the community write fence across the complete catalog of community-scoped tables. Startup/readiness and destructive execution validate that catalog so a newly added but unfenced table cannot silently escape. - **Named isolation assumption — fresh write snapshot.** Every writer transaction that can reach a community-fenced relation must use PostgreSQL `READ COMMITTED`; each guarded write therefore observes a statement snapshot no older than acquisition of the community deletion lock. `REPEATABLE READ` and `SERIALIZABLE` can retain a pre-fence snapshot and are unsupported for writers. The writer pool refuses non-`READ COMMITTED` sessions at connection setup, and both SQL fence functions reject an explicit per-transaction isolation override with SQLSTATE `25000`. Configuration-delivered bad isolation can surface through SQLx as a pool-acquire timeout because every `after_connect` attempt is rejected; the precise `community writes require READ COMMITTED isolation` reason remains observable when the SQL guard is reached. Read-only replica transactions are outside this assumption. - Holding the shared advisory lock until the guarded write executes is a separate liveness condition: under `READ COMMITTED`, releasing it early does not permit resurrection because the trigger rechecks the fence, but it can turn a fleet sweep into a statement-wide SQLSTATE `55000` abort. - After the fence closes writers, storage is re-enumerated into chunked side-table rows. Per-prefix counts and digests bind those concrete keys to the destructive manifest. - Manifest chunk insertion, update, and deletion are protected after freeze. This closes the race where an unbound key could otherwise appear after the manifest was committed. ### 3. Checkpointed destruction - Target-owned object bindings are deleted from the frozen destructive manifest in bounded batches with durable progress. - The concrete key list lives in chunked side-table rows rather than one request-row JSON value. It supports large communities, resumable execution, and terminal cleanup. - Missing objects are accepted as idempotent crash-window outcomes; malformed ownership, changed evidence, and unexplained target-prefix drift fail closed. - PostgreSQL purging remains scoped by `community_id`, including the guarded NIP-RS hard-delete path discovered with real Desktop kind `30078` read-state data. - Redis cleanup explicitly scans and `UNLINK`s only `buzz:<community_id>:*`. Natural expiry is insufficient because some keys, including tunnel generation counters used as fencing state, are deliberately persistent. ### 4. Independent verification - PostgreSQL logical absence is checked after purge. - The three target-owned storage prefixes are freshly inventoried again and must be empty. - Redis requires two complete empty namespace scans. - Only after all three stores pass does the request advance through `logically_verified` to `retention_pending`. ## What V1 deliberately does not erase ### Shared content-addressed storage Per-community deletion removes bindings, metadata, attribution records, and Git pointers. It does **not** physically delete fleet-shared CAS bytes that another community may still reference: - media blobs and thumbnails - Git manifests, packs, and indexes (`manifests/`, `packs/`, and `idx/`) Safe reclamation requires a separate fleet-wide reachability and retention GC. Unknown keys elsewhere in the shared bucket do not block one community's deletion; malformed or unrecognized keys inside that community's three owned prefixes still fail closed. ### External retained copies The online logical-deletion proof does not erase object versions/replicas, database backups/WAL, CDN copies, provider retention copies, or observability exports. Those require their own retention and purge controls. ### Member-only erasure This PR erases a whole community. It does not implement the different operation "erase one npub while preserving the community." Removing membership or accepting NIP-09 is not member erasure. A member-only workflow would need to find and selectively remove or redact authored event content and pubkeys, profile data, DMs, reactions, mentions, memberships/roles, tokens, workflows/subscriptions, upload attribution, moderation/audit history, repository attribution, and identity embedded in tags or JSON. It would also need explicit rules for ownership transfer, surviving replies and thread metadata, audit-chain integrity, immutable Git history, and shared-CAS reachability. That requires a pubkey-level fence and selective graph rewrite; it is a separate deletion product, not a safe extension of this whole-tenant worker. ## In scope - migration `0029_community_deletion.sql`: requests, approvals, leases, manifest chunks, checkpoints, tombstones, and the universal write-fence catalog - durable executor leases, generations, heartbeats, retry/block state, and resumable stage transitions - operator-driven `sweep`, `submit`, `list`, `inspect`, `approve`, `unblock`, `run`, and `drain` commands - serving-path fences for database writes and external effects across event ingest, media, Git, workflow, push, invites, mesh/tunnel, and related paths - target-prefix-only storage inventory, summary manifests, post-fence destructive chunks, and bounded batch deletion - exact community Redis namespace purge and two-pass absence verification - cross-community isolation, crash/resume, manifest-integrity, writer-taxonomy, and schema/migration regressions - desired-state `schema/schema.sql` support without requiring a SQLx migration ledger ## Deferred / not covered - dedicated Helm/chart worker Deployment, service account, secrets, probes, resources, and network policy - autonomous `buzz-admin deletions worker` poll loop and worker-only health server - least-privilege separation among migration, relay-serving, and destructive execution roles - fleet-wide shared-CAS physical GC - backup/provider/CDN/observability retention completion - member-only erasure - provider-native conditional-delete improvements - a general force-continue escape hatch; permanent safety failures remain fail closed unless an operator remediates the cause and records an audited `unblock` The removed continuous-worker implementation remains deferred; no remote follow-up branch is claimed by this PR. ## Validation ### Current PR head and repository state Current pushed head: `359d8402ee15f049768f54156f67b953c7a7e2ed`, rebased onto `cc9a2f783375e51a6e8d1f2f9d01d5f7e22813d1` (`origin/main` at push time). The complete PR diff is now 47 files, 9,834 additions, and 517 deletions. The bespoke source-scanner stack was removed to keep this PR scoped to community deletion. Tyler/team requested the underlying fenced-write safety behavior, not `ast-grep`, `crates/buzz-db/tests/community_fenced_writes.rs`, its 27 fixtures, or the new `scripts/lints/community_*.yml` rules. Those scanner-specific files, dependencies, Hermit links, and runner wiring are absent from the current tree. The production database write fence, startup/destructive live-catalog validation, and deletion behavior remain. Source validation on this exact SHA passed: - `cargo fmt --all -- --check` - `bash -n scripts/run-tests.sh` - `cargo nextest run -p buzz-db --all-targets`: 102 passed, 173 skipped, 0 failed - `cargo nextest run -p buzz-deletion --all-targets`: 10 passed, 9 skipped, 0 failed - `cargo nextest run -p buzz-admin --all-targets`: 1 passed, 0 failed - affected-package/all-target Clippy with warnings denied - lockfile consistency - Helm 3.16.4 lint and all 44 chart unit tests - Helm region controls using that fixture: default `BUZZ_S3_REGION=us-east-1`, explicit `eu-west-2` override, and blank-region schema rejection The prior Kubernetes battery below was run against `928992237358a3294621ac0280830b77155abc04`. It remains useful evidence for the patch-equivalent production deletion implementation, but it is **not** claimed as exact-SHA evidence for current head `359d8402ee15f049768f54156f67b953c7a7e2ed`; the current cleanup removes only scanner/test/tooling infrastructure. CI restarted for the new head after the rebase and is pending. Human review remains `CHANGES_REQUESTED`. ### Prior-head live Kubernetes deletion and safety gates The full program used one immutable image, real PostgreSQL, Redis, MinIO, and a three-relay Kubernetes release: - source: `928992237358a3294621ac0280830b77155abc04` (**prior head**) - image: `buzz-e2e:sha-928992237358` - immutable image digest: `sha256:a1a204f4618ac22d9e210be5e5290645a15d79831ae30b0e44379357c8e4a895` - evidence root: `/tmp/buzz-e2e/20260807T033025Z-928992237358-full-gates/` - evidence-manifest digest: `82875c5bc9bea7370b796a7aef3457b3a1c8306c84c59e0f7388bbb5ad30e865` Passed gates at that prior head: - **Chart/operator region:** default `us-east-1`, explicit nondefault propagation, blank-region schema rejection, live in-pod environment, and an in-pod taxonomy sweep over 18 objects with zero unknown. - **Fenced writers and lifecycle:** open-write/fence ordering; 100-attempt anti-starvation; invite, push matcher, and exhausted-reaper bystander isolation; non-`READ-COMMITTED` rejection; manifest/tombstone contracts; eight-failure stage block and audited `unblock`. - **Destructive lifecycle:** submit → approve → run → `retention_pending`; PostgreSQL tombstone and Redis/S3 verification true; zero retries/errors; terminal reruns rejected with exit 5. - **Fresh 10,001-object crash boundary:** exactly two chunks (10,000 + 1). The executor deleted chunk 0 from MinIO while its PostgreSQL stamp was row-lock-blocked, was killed with `SIGKILL`, left one object and both stamps absent, then resumed the same request under generation 2 to zero objects and terminal state. - **Independent dead-owner recovery:** a dedicated executor claimed generation 1, blocked before effects, and was killed through containerd with `SIGKILL` (no TERM cleanup). The request remained owned and unreclaimable before lease expiry; a successor claimed generation 2 after 60 seconds and completed with two attempts and zero retries. - **Three-pod socket isolation:** ordinary NIP-42 and joined huddle-audio target witnesses on every replica received exact `1008 / community deleted`; healthy-tenant witnesses on those pods remained live; deleted-host reconnect returned HTTP 404. - **Health/provenance:** all replicas independently returned ready and retained the exact image digest before/after destructive runs and an audio-enabled rolling restart; PostgreSQL, Redis, and MinIO were healthy at close. Instrument corrections were retained as evidence rather than counted as product failures: a foreground PostgreSQL forward caused an initial `PoolTimedOut`; Kubernetes pod deletion exercised graceful TERM rather than dead-owner recovery; shell-background socket witnesses died with their parent; and the first image build hit the corporate TLS proxy. Detached forwarding/witnesses, containerd `SIGKILL`, and the configured internal CA/Artifactory mirror produced the discriminating runs without weakening product security. ### Prior-head cleanup For the prior-head Kubernetes run, the Helm release was removed, namespace absence was verified, run-owned Screen sessions were absent, and that source worktree remained clean. The evidence manifest was independently recomputed and every indexed artifact passed `shasum -a 256 -c`. The current `359d8402` source worktree is also clean after the scanner-only cleanup and push. --------- Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
1112 lines
39 KiB
Rust
1112 lines
39 KiB
Rust
//! WebSocket connection lifecycle: semaphore → challenge → recv/send/heartbeat loops → cleanup.
|
|
|
|
use std::collections::HashMap;
|
|
use std::net::SocketAddr;
|
|
use std::sync::atomic::{AtomicU8, Ordering};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use axum::extract::ws::{Message as WsMessage, WebSocket};
|
|
use futures_util::{Sink, SinkExt, StreamExt};
|
|
use tokio::sync::{mpsc, watch, Mutex, RwLock};
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::Instrument as _;
|
|
use tracing::{debug, info, trace, warn};
|
|
use uuid::Uuid;
|
|
|
|
use buzz_auth::{generate_challenge, AuthContext, LimitType};
|
|
use buzz_core::tenant::TenantContext;
|
|
use nostr::Filter;
|
|
|
|
use crate::handlers;
|
|
use crate::protocol::{ClientMessage, RelayMessage};
|
|
use crate::state::{
|
|
run_registered_community_connection, AppState, CommunityConnectionControl,
|
|
CommunityDisconnectReason,
|
|
};
|
|
use buzz_pubsub::EventTopic;
|
|
|
|
/// Maximum time a new socket may hold a connection slot without completing NIP-42 auth.
|
|
const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
|
|
|
|
/// Shared mutable subscription map for a single WebSocket connection.
|
|
pub(crate) type ConnectionSubscriptions = Arc<Mutex<HashMap<String, Vec<Filter>>>>;
|
|
|
|
/// Request for the writer to flush a restart close and report the result.
|
|
pub(crate) struct RestartClose {
|
|
pub(crate) flushed: tokio::sync::oneshot::Sender<bool>,
|
|
}
|
|
|
|
/// Maximum outbound data frames buffered into the websocket sink before one flush.
|
|
const MAX_WS_SEND_BATCH: usize = 64;
|
|
|
|
/// NIP-42 authentication state for a single connection.
|
|
#[derive(Debug, Clone)]
|
|
pub enum AuthState {
|
|
/// Challenge has been sent; awaiting a signed AUTH event from the client.
|
|
Pending {
|
|
/// The random challenge string sent to the client.
|
|
challenge: String,
|
|
},
|
|
/// Client has successfully authenticated.
|
|
Authenticated(AuthContext),
|
|
/// Authentication attempt was rejected.
|
|
Failed,
|
|
}
|
|
|
|
/// Per-connection state split by access pattern:
|
|
/// - `auth_state`: RwLock (read-heavy after initial auth)
|
|
/// - `subscriptions`: Mutex (write-heavy during REQ/CLOSE)
|
|
/// - `send_tx`, `ctrl_tx`, `cancel`: outside any lock (Clone+Send, no coordination needed)
|
|
pub struct ConnectionState {
|
|
/// Unique identifier for this connection.
|
|
pub conn_id: Uuid,
|
|
/// The community this connection is bound to, resolved from the connection
|
|
/// host at row zero (before any frame is read) and never overridable by
|
|
/// client-supplied input. Every handler reads tenant scope from here.
|
|
pub tenant: TenantContext,
|
|
/// Remote socket address of the client.
|
|
pub remote_addr: SocketAddr,
|
|
/// Current NIP-42 authentication state.
|
|
pub auth_state: RwLock<AuthState>,
|
|
/// Active subscriptions keyed by subscription ID.
|
|
pub subscriptions: ConnectionSubscriptions,
|
|
/// Sender for outbound data messages (EVENT, NOTICE, OK, etc.).
|
|
pub send_tx: mpsc::Sender<WsMessage>,
|
|
/// Sender for outbound control frames (Pong, Close).
|
|
/// Separate channel with priority drain — if this channel fills too,
|
|
/// the connection is closed (writer is completely stalled).
|
|
pub ctrl_tx: mpsc::Sender<WsMessage>,
|
|
/// Token used to signal graceful shutdown of this connection's tasks.
|
|
pub cancel: CancellationToken,
|
|
/// Consecutive buffer-full events. Cancel only after `grace_limit`.
|
|
/// Shared with `ConnectionManager::ConnEntry` so both direct sends and
|
|
/// fan-out broadcasts track the same counter.
|
|
pub backpressure_count: Arc<AtomicU8>,
|
|
/// Configurable slow-client grace limit (from `Config::slow_client_grace_limit`).
|
|
pub grace_limit: u8,
|
|
}
|
|
|
|
impl ConnectionState {
|
|
/// Sends a data message to this connection's outbound channel.
|
|
///
|
|
/// On a full buffer, increments the backpressure counter. The first
|
|
/// `grace_limit` occurrences log a warning; sustained backpressure
|
|
/// cancels the connection to prevent unbounded memory growth.
|
|
pub fn send(&self, msg: String) -> bool {
|
|
match self.send_tx.try_send(WsMessage::Text(msg.into())) {
|
|
Ok(_) => {
|
|
// Successful send resets the grace counter.
|
|
self.backpressure_count.store(0, Ordering::Relaxed);
|
|
true
|
|
}
|
|
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
|
let count = self.backpressure_count.fetch_add(1, Ordering::Relaxed) + 1;
|
|
if count >= self.grace_limit {
|
|
warn!(conn_id = %self.conn_id, count, "sustained backpressure — closing slow client");
|
|
metrics::counter!("buzz_ws_backpressure_disconnects_total").increment(1);
|
|
self.cancel.cancel();
|
|
} else {
|
|
warn!(conn_id = %self.conn_id, count, grace = self.grace_limit, "send buffer full — grace {count}/{}", self.grace_limit);
|
|
}
|
|
false
|
|
}
|
|
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
|
debug!(conn_id = %self.conn_id, "send channel closed");
|
|
false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Entry point for a new WebSocket connection.
|
|
///
|
|
/// Acquires a connection semaphore permit, sends the NIP-42 AUTH challenge,
|
|
/// then drives the send, heartbeat, and receive loops until the connection closes.
|
|
pub async fn handle_connection(
|
|
socket: WebSocket,
|
|
state: Arc<AppState>,
|
|
addr: SocketAddr,
|
|
tenant: TenantContext,
|
|
) {
|
|
let conn_id = Uuid::new_v4();
|
|
let cancel = CancellationToken::new();
|
|
let control = CommunityConnectionControl::new(cancel);
|
|
let community_id = tenant.community();
|
|
let registry = Arc::clone(&state.community_connections);
|
|
let check_state = Arc::clone(&state);
|
|
let run_state = Arc::clone(&state);
|
|
run_registered_community_connection(
|
|
®istry,
|
|
conn_id,
|
|
community_id,
|
|
control,
|
|
move || async move { check_state.db.is_community_active(community_id).await },
|
|
move |control| handle_active_connection(socket, run_state, addr, tenant, conn_id, control),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn handle_active_connection(
|
|
socket: WebSocket,
|
|
state: Arc<AppState>,
|
|
addr: SocketAddr,
|
|
tenant: TenantContext,
|
|
conn_id: Uuid,
|
|
control: CommunityConnectionControl,
|
|
) {
|
|
let cancel = control.cancellation_token();
|
|
let disconnect_reason = control.disconnect_reason();
|
|
let permit = match state.conn_semaphore.clone().try_acquire_owned() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
warn!("Connection limit reached, rejecting {addr}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
let challenge = generate_challenge();
|
|
|
|
let (tx, rx) = mpsc::channel::<WsMessage>(state.config.send_buffer_size);
|
|
// Control channel for Pong/Close — small capacity, guaranteed delivery
|
|
// even when the data buffer is full.
|
|
let (ctrl_tx, ctrl_rx) = mpsc::channel::<WsMessage>(8);
|
|
|
|
// Dedicated restart-close channel carries a flush acknowledgement. Keeping
|
|
// ordinary control frames unchanged avoids coupling heartbeat/ban traffic
|
|
// to graceful-shutdown delivery tracking.
|
|
let (restart_tx, restart_rx) = mpsc::channel::<RestartClose>(1);
|
|
|
|
let backpressure_count = Arc::new(AtomicU8::new(0));
|
|
let subscriptions = Arc::new(Mutex::new(HashMap::new()));
|
|
|
|
let conn = Arc::new(ConnectionState {
|
|
conn_id,
|
|
tenant,
|
|
remote_addr: addr,
|
|
auth_state: RwLock::new(AuthState::Pending {
|
|
challenge: challenge.clone(),
|
|
}),
|
|
subscriptions: Arc::clone(&subscriptions),
|
|
send_tx: tx.clone(),
|
|
ctrl_tx: ctrl_tx.clone(),
|
|
cancel: cancel.clone(),
|
|
backpressure_count: Arc::clone(&backpressure_count),
|
|
grace_limit: state.config.slow_client_grace_limit,
|
|
});
|
|
|
|
info!(conn_id = %conn_id, addr = %addr, "WebSocket connection established");
|
|
metrics::counter!(
|
|
"buzz_ws_connections_total",
|
|
"community" => conn.tenant.host().to_owned()
|
|
)
|
|
.increment(1);
|
|
|
|
let challenge_msg = RelayMessage::auth_challenge(&challenge);
|
|
if tx
|
|
.send(WsMessage::Text(challenge_msg.into()))
|
|
.await
|
|
.is_err()
|
|
{
|
|
warn!(conn_id = %conn_id, "Failed to send AUTH challenge — client disconnected immediately");
|
|
return;
|
|
}
|
|
|
|
// Gauge incremented AFTER challenge send succeeds — early disconnects
|
|
// don't leak. Decremented in the cleanup path below.
|
|
metrics::gauge!("buzz_ws_connections_active").increment(1.0);
|
|
|
|
// Register after challenge succeeds — avoids leaked entries on early disconnect.
|
|
state.conn_manager.register(
|
|
conn_id,
|
|
tx.clone(),
|
|
ctrl_tx.clone(),
|
|
Some(restart_tx),
|
|
cancel.clone(),
|
|
conn.tenant.community(),
|
|
Arc::clone(&backpressure_count),
|
|
subscriptions,
|
|
state.config.slow_client_grace_limit,
|
|
);
|
|
|
|
let (ws_send, ws_recv) = socket.split();
|
|
|
|
let send_cancel = cancel.child_token();
|
|
let send_task = tokio::spawn(send_loop(
|
|
ws_send,
|
|
rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
send_cancel,
|
|
disconnect_reason,
|
|
));
|
|
|
|
let missed_pongs = Arc::new(AtomicU8::new(0));
|
|
let heartbeat_cancel = cancel.clone();
|
|
let heartbeat_task = tokio::spawn(heartbeat_loop(
|
|
ctrl_tx,
|
|
Arc::clone(&missed_pongs),
|
|
heartbeat_cancel,
|
|
));
|
|
|
|
let auth_timeout_conn = Arc::clone(&conn);
|
|
let auth_timeout_cancel = cancel.clone();
|
|
let auth_timeout_task = tokio::spawn(async move {
|
|
tokio::select! {
|
|
_ = tokio::time::sleep(AUTH_TIMEOUT) => {
|
|
let authenticated = matches!(
|
|
*auth_timeout_conn.auth_state.read().await,
|
|
AuthState::Authenticated(_)
|
|
);
|
|
if !authenticated {
|
|
warn!(
|
|
conn_id = %auth_timeout_conn.conn_id,
|
|
timeout_secs = AUTH_TIMEOUT.as_secs(),
|
|
"NIP-42 auth timeout — closing connection"
|
|
);
|
|
metrics::counter!("buzz_ws_auth_timeouts_total").increment(1);
|
|
auth_timeout_cancel.cancel();
|
|
}
|
|
}
|
|
_ = auth_timeout_cancel.cancelled() => {}
|
|
}
|
|
});
|
|
|
|
recv_loop(
|
|
ws_recv,
|
|
Arc::clone(&conn),
|
|
Arc::clone(&state),
|
|
Arc::clone(&missed_pongs),
|
|
cancel.clone(),
|
|
)
|
|
.await;
|
|
|
|
cancel.cancel();
|
|
let _ = send_task.await;
|
|
let _ = heartbeat_task.await;
|
|
let _ = auth_timeout_task.await;
|
|
|
|
for removed in state.sub_registry.remove_connection(conn.conn_id) {
|
|
state
|
|
.pubsub
|
|
.release_topic(&conn.tenant, topic_for_subscription(removed.channel_id))
|
|
.await;
|
|
}
|
|
state.conn_manager.deregister(conn.conn_id);
|
|
if let AuthState::Authenticated(ref auth_ctx) = *conn.auth_state.read().await {
|
|
let remaining = state.conn_manager.connection_ids_for_pubkey_in_community(
|
|
conn.tenant.community(),
|
|
auth_ctx.pubkey.to_bytes().as_slice(),
|
|
);
|
|
if remaining.is_empty() {
|
|
let _ = state
|
|
.pubsub
|
|
.clear_presence(&conn.tenant, &auth_ctx.pubkey)
|
|
.await;
|
|
}
|
|
}
|
|
metrics::gauge!("buzz_ws_connections_active").decrement(1.0);
|
|
info!(conn_id = %conn_id, addr = %addr, "WebSocket connection closed");
|
|
|
|
drop(permit);
|
|
}
|
|
|
|
/// Outbound send loop with control-frame priority.
|
|
///
|
|
/// Control frames (Pong, Close) are drained first on every iteration,
|
|
/// giving them priority over data frames. If the underlying socket writer
|
|
/// is stalled, control frames queue in the small ctrl_rx buffer; callers
|
|
/// treat a full control channel as terminal (Bug 7 fix).
|
|
async fn send_loop(
|
|
ws_send: futures_util::stream::SplitSink<WebSocket, WsMessage>,
|
|
data_rx: mpsc::Receiver<WsMessage>,
|
|
ctrl_rx: mpsc::Receiver<WsMessage>,
|
|
restart_rx: mpsc::Receiver<RestartClose>,
|
|
cancel: CancellationToken,
|
|
disconnect_reason: watch::Receiver<Option<CommunityDisconnectReason>>,
|
|
) {
|
|
send_loop_inner(
|
|
ws_send,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
cancel,
|
|
disconnect_reason,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn send_loop_inner<S>(
|
|
mut ws_send: S,
|
|
mut data_rx: mpsc::Receiver<WsMessage>,
|
|
mut ctrl_rx: mpsc::Receiver<WsMessage>,
|
|
mut restart_rx: mpsc::Receiver<RestartClose>,
|
|
cancel: CancellationToken,
|
|
disconnect_reason: watch::Receiver<Option<CommunityDisconnectReason>>,
|
|
) where
|
|
S: Sink<WsMessage> + Unpin,
|
|
{
|
|
loop {
|
|
// Priority: drain all pending control frames before data.
|
|
while let Ok(ctrl_msg) = ctrl_rx.try_recv() {
|
|
if ws_send.send(ctrl_msg).await.is_err() {
|
|
return;
|
|
}
|
|
}
|
|
|
|
tokio::select! {
|
|
// Biased: restart > cancel > ordinary control > data. A restart
|
|
// command owns shutdown delivery and must flush its 1012 before
|
|
// cancellation can fall back to an unacknowledged close.
|
|
biased;
|
|
Some(restart) = restart_rx.recv() => {
|
|
let sent = ws_send
|
|
.send(WsMessage::Close(Some(axum::extract::ws::CloseFrame {
|
|
code: axum::extract::ws::close_code::RESTART,
|
|
reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"),
|
|
})))
|
|
.await
|
|
.is_ok();
|
|
let _ = restart.flushed.send(sent);
|
|
break;
|
|
}
|
|
_ = cancel.cancelled() => {
|
|
// Drain any queued control frames before closing. A ban
|
|
// disconnect queues its `OK false "blocked: …"` reason frame on
|
|
// ctrl and then cancels; without this drain the biased branch
|
|
// would send Close first and the client would never learn why
|
|
// (the top-of-loop drain does not run again after we break).
|
|
// This makes "queue frame on ctrl, then cancel" a safe idiom.
|
|
while let Ok(ctrl_msg) = ctrl_rx.try_recv() {
|
|
if ws_send.send(ctrl_msg).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
let close = disconnect_reason
|
|
.borrow()
|
|
.map_or(WsMessage::Close(None), |reason| reason.close_message());
|
|
let _ = ws_send.send(close).await;
|
|
break;
|
|
}
|
|
Some(ctrl_msg) = ctrl_rx.recv() => {
|
|
if ws_send.send(ctrl_msg).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Some(msg) = data_rx.recv() => {
|
|
let mut batched = 1usize;
|
|
if ws_send.feed(msg).await.is_err() {
|
|
break;
|
|
}
|
|
|
|
while batched < MAX_WS_SEND_BATCH {
|
|
match data_rx.try_recv() {
|
|
Ok(next) => {
|
|
if ws_send.feed(next).await.is_err() {
|
|
return;
|
|
}
|
|
batched += 1;
|
|
}
|
|
Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
|
|
Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break,
|
|
}
|
|
}
|
|
|
|
if ws_send.flush().await.is_err() {
|
|
break;
|
|
}
|
|
metrics::histogram!("buzz_ws_send_batch_size").record(batched as f64);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 3 missed pongs → disconnect.
|
|
///
|
|
/// Sends Ping through the control channel so it isn't blocked by a full
|
|
/// data buffer. Uses `try_send` to keep the select loop responsive to
|
|
/// cancellation — a full control channel means the writer is stalled.
|
|
async fn heartbeat_loop(
|
|
ctrl_tx: mpsc::Sender<WsMessage>,
|
|
missed_pongs: Arc<AtomicU8>,
|
|
cancel: CancellationToken,
|
|
) {
|
|
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
|
loop {
|
|
tokio::select! {
|
|
_ = interval.tick() => {
|
|
// fetch_add returns the *previous* value before incrementing:
|
|
// prev=0 → now 1 (first miss)
|
|
// prev=1 → now 2 (second miss)
|
|
// prev=2 → now 3 (third miss → disconnect)
|
|
let missed = missed_pongs.fetch_add(1, Ordering::Relaxed);
|
|
if missed >= 2 {
|
|
warn!("3 missed pongs — closing connection");
|
|
cancel.cancel();
|
|
break;
|
|
}
|
|
if ctrl_tx.try_send(WsMessage::Ping(axum::body::Bytes::new())).is_err() {
|
|
warn!("control channel full — cannot send Ping, closing");
|
|
cancel.cancel();
|
|
break;
|
|
}
|
|
}
|
|
_ = cancel.cancelled() => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn recv_loop(
|
|
mut ws_recv: futures_util::stream::SplitStream<WebSocket>,
|
|
conn: Arc<ConnectionState>,
|
|
state: Arc<AppState>,
|
|
missed_pongs: Arc<AtomicU8>,
|
|
cancel: CancellationToken,
|
|
) {
|
|
loop {
|
|
tokio::select! {
|
|
msg = ws_recv.next() => {
|
|
match msg {
|
|
Some(Ok(WsMessage::Text(text))) => {
|
|
let max_frame_bytes = state.config.max_frame_bytes;
|
|
if text.len() > max_frame_bytes {
|
|
warn!(
|
|
conn_id = %conn.conn_id,
|
|
bytes = text.len(),
|
|
max_frame_bytes,
|
|
"frame too large — disconnecting"
|
|
);
|
|
conn.send(format!(
|
|
r#"["NOTICE","error: frame too large ({} bytes, limit {})"]"#,
|
|
text.len(),
|
|
max_frame_bytes
|
|
));
|
|
break;
|
|
}
|
|
trace!(len = text.len(), "frame received");
|
|
handle_text_message(text.to_string(), Arc::clone(&conn), Arc::clone(&state)).await;
|
|
}
|
|
Some(Ok(WsMessage::Binary(bytes))) => {
|
|
let max_frame_bytes = state.config.max_frame_bytes;
|
|
if bytes.len() > max_frame_bytes {
|
|
warn!(
|
|
conn_id = %conn.conn_id,
|
|
bytes = bytes.len(),
|
|
max_frame_bytes,
|
|
"binary frame too large — disconnecting"
|
|
);
|
|
conn.send(format!(
|
|
r#"["NOTICE","error: binary frame too large ({} bytes, limit {})"]"#,
|
|
bytes.len(),
|
|
max_frame_bytes
|
|
));
|
|
break;
|
|
}
|
|
// Binary frames: attempt UTF-8 decode and treat as text. Some clients
|
|
// (notably certain Nostr libraries) send text payloads in binary frames.
|
|
// NIP-01 is text-only, but accepting binary is a common relay extension.
|
|
if let Ok(text) = String::from_utf8(bytes.to_vec()) {
|
|
handle_text_message(text, Arc::clone(&conn), Arc::clone(&state)).await;
|
|
}
|
|
}
|
|
Some(Ok(WsMessage::Pong(_))) => {
|
|
missed_pongs.store(0, Ordering::Relaxed);
|
|
}
|
|
Some(Ok(WsMessage::Ping(data))) => {
|
|
// Send Pong through the control channel — priority
|
|
// delivery even when the data buffer is full (Bug 7 fix).
|
|
if conn.ctrl_tx.try_send(WsMessage::Pong(data)).is_err() {
|
|
// Control channel full means the socket writer is
|
|
// completely stalled — treat as terminal.
|
|
warn!(conn_id = %conn.conn_id, "control channel full — cannot send Pong, closing");
|
|
break;
|
|
}
|
|
}
|
|
Some(Ok(WsMessage::Close(_))) | None => {
|
|
debug!("WebSocket closed by client");
|
|
break;
|
|
}
|
|
Some(Err(e)) => {
|
|
debug!("WebSocket error: {e}");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
_ = cancel.cancelled() => break,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Arc<AppState>) {
|
|
let msg = match ClientMessage::parse(&text) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
conn.send(RelayMessage::notice(&format!("invalid message: {e}")));
|
|
return;
|
|
}
|
|
};
|
|
|
|
if !enforce_ws_admission(&msg, &conn, &state).await {
|
|
return;
|
|
}
|
|
|
|
match msg {
|
|
ClientMessage::Auth(event) => {
|
|
// Auth is synchronous in the WS loop — no span context is lost.
|
|
let span = tracing::info_span!("ws.auth", conn_id = %conn.conn_id);
|
|
handlers::auth::handle_auth(event, Arc::clone(&conn), Arc::clone(&state))
|
|
.instrument(span)
|
|
.await;
|
|
}
|
|
ClientMessage::Event(event) => {
|
|
let conn = Arc::clone(&conn);
|
|
let state = Arc::clone(&state);
|
|
let permit = match state.handler_semaphore.clone().try_acquire_owned() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
conn.send(RelayMessage::notice(
|
|
"rate-limited: too many concurrent requests",
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
// Capture the parent span BEFORE the spawn so it is propagated into
|
|
// the spawned future. A bare `tokio::spawn` drops tracing context.
|
|
let span = tracing::info_span!(
|
|
"ws.event",
|
|
conn_id = %conn.conn_id,
|
|
event_id = tracing::field::Empty,
|
|
kind = tracing::field::Empty,
|
|
);
|
|
tokio::spawn(
|
|
async move {
|
|
handlers::event::handle_event(event, conn, state).await;
|
|
drop(permit);
|
|
}
|
|
.instrument(span),
|
|
);
|
|
}
|
|
ClientMessage::Req { sub_id, filters } => {
|
|
let conn = Arc::clone(&conn);
|
|
let state = Arc::clone(&state);
|
|
let permit = match state.handler_semaphore.clone().try_acquire_owned() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
conn.send(request_rejection_message(
|
|
Some(&sub_id),
|
|
"rate-limited: too many concurrent requests",
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
let span = tracing::info_span!("ws.req", conn_id = %conn.conn_id, sub_id = %sub_id);
|
|
tokio::spawn(
|
|
async move {
|
|
handlers::req::handle_req(sub_id, filters, conn, state).await;
|
|
drop(permit);
|
|
}
|
|
.instrument(span),
|
|
);
|
|
}
|
|
ClientMessage::Count { sub_id, filters } => {
|
|
let conn = Arc::clone(&conn);
|
|
let state = Arc::clone(&state);
|
|
let permit = match state.handler_semaphore.clone().try_acquire_owned() {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
conn.send(RelayMessage::notice(
|
|
"rate-limited: too many concurrent requests",
|
|
));
|
|
return;
|
|
}
|
|
};
|
|
let span = tracing::info_span!("ws.count", conn_id = %conn.conn_id, sub_id = %sub_id);
|
|
tokio::spawn(
|
|
async move {
|
|
handlers::count::handle_count(sub_id, filters, conn, state).await;
|
|
drop(permit);
|
|
}
|
|
.instrument(span),
|
|
);
|
|
}
|
|
ClientMessage::Close(sub_id) => {
|
|
handlers::close::handle_close(sub_id, Arc::clone(&conn), Arc::clone(&state)).await;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn request_rejection_message(sub_id: Option<&str>, reason: &str) -> String {
|
|
match sub_id {
|
|
Some(sub_id) => RelayMessage::closed(sub_id, reason),
|
|
None => RelayMessage::notice(reason),
|
|
}
|
|
}
|
|
|
|
async fn enforce_ws_admission(
|
|
msg: &ClientMessage,
|
|
conn: &ConnectionState,
|
|
state: &AppState,
|
|
) -> bool {
|
|
let is_event = matches!(msg, ClientMessage::Event(_));
|
|
if !is_event && !matches!(msg, ClientMessage::Req { .. } | ClientMessage::Count { .. }) {
|
|
return true;
|
|
}
|
|
|
|
let (pubkey, is_agent) = {
|
|
let auth = conn.auth_state.read().await;
|
|
match &*auth {
|
|
AuthState::Authenticated(ctx) => (ctx.pubkey, ctx.agent_owner_pubkey.is_some()),
|
|
_ => return true,
|
|
}
|
|
};
|
|
|
|
let limits = &state.auth.config().rate_limits;
|
|
let (ws_window_secs, ws_limit) =
|
|
crate::admission::ws_admission_budget(limits.human_ws_events_per_sec);
|
|
let ws_result = crate::admission::check_principal(
|
|
state.admission_rate_limiter.as_ref(),
|
|
&conn.tenant,
|
|
&pubkey,
|
|
LimitType::WsEvents,
|
|
ws_window_secs,
|
|
ws_limit,
|
|
)
|
|
.await;
|
|
let sub_id = match msg {
|
|
ClientMessage::Req { sub_id, .. } => Some(sub_id.as_str()),
|
|
_ => None,
|
|
};
|
|
if !send_admission_result(conn, ws_result, sub_id) {
|
|
return false;
|
|
}
|
|
|
|
if is_event {
|
|
let message_limit = if is_agent {
|
|
limits.agent_standard_messages_per_min
|
|
} else {
|
|
limits.human_messages_per_min
|
|
};
|
|
let message_result = crate::admission::check_principal(
|
|
state.admission_rate_limiter.as_ref(),
|
|
&conn.tenant,
|
|
&pubkey,
|
|
LimitType::Messages,
|
|
60,
|
|
message_limit,
|
|
)
|
|
.await;
|
|
if !send_admission_result(conn, message_result, None) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn send_admission_result(
|
|
conn: &ConnectionState,
|
|
result: Result<(), crate::admission::AdmissionError>,
|
|
sub_id: Option<&str>,
|
|
) -> bool {
|
|
match result {
|
|
Ok(()) => true,
|
|
Err(crate::admission::AdmissionError::Exceeded { reset_in_secs }) => {
|
|
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "quota").increment(1);
|
|
conn.send(request_rejection_message(
|
|
sub_id,
|
|
&format!("rate-limited: quota exceeded; retry in {reset_in_secs}s"),
|
|
));
|
|
false
|
|
}
|
|
Err(crate::admission::AdmissionError::Unavailable) => {
|
|
metrics::counter!("buzz_admission_rejections_total", "transport" => "websocket", "reason" => "unavailable").increment(1);
|
|
conn.send(request_rejection_message(
|
|
sub_id,
|
|
"rate-limited: shared admission unavailable",
|
|
));
|
|
false
|
|
}
|
|
}
|
|
}
|
|
|
|
fn topic_for_subscription(channel_id: Option<Uuid>) -> EventTopic {
|
|
match channel_id {
|
|
Some(channel_id) => EventTopic::Channel(channel_id),
|
|
None => EventTopic::Global,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[derive(Debug, Default)]
|
|
struct MockSinkState {
|
|
messages: Vec<WsMessage>,
|
|
flush_count: usize,
|
|
fail_after_flushes: Option<usize>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct MockSink {
|
|
state: Arc<Mutex<MockSinkState>>,
|
|
}
|
|
|
|
impl MockSink {
|
|
fn new(fail_after_flushes: Option<usize>) -> (Self, Arc<Mutex<MockSinkState>>) {
|
|
let state = Arc::new(Mutex::new(MockSinkState {
|
|
fail_after_flushes,
|
|
..MockSinkState::default()
|
|
}));
|
|
(
|
|
Self {
|
|
state: Arc::clone(&state),
|
|
},
|
|
state,
|
|
)
|
|
}
|
|
}
|
|
|
|
impl Sink<WsMessage> for MockSink {
|
|
type Error = std::io::Error;
|
|
|
|
fn poll_ready(
|
|
self: std::pin::Pin<&mut Self>,
|
|
_cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<Result<(), Self::Error>> {
|
|
std::task::Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn start_send(self: std::pin::Pin<&mut Self>, item: WsMessage) -> Result<(), Self::Error> {
|
|
self.state
|
|
.lock()
|
|
.expect("mock sink poisoned")
|
|
.messages
|
|
.push(item);
|
|
Ok(())
|
|
}
|
|
|
|
fn poll_flush(
|
|
self: std::pin::Pin<&mut Self>,
|
|
_cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<Result<(), Self::Error>> {
|
|
let mut state = self.state.lock().expect("mock sink poisoned");
|
|
state.flush_count += 1;
|
|
if state
|
|
.fail_after_flushes
|
|
.is_some_and(|limit| state.flush_count >= limit)
|
|
{
|
|
return std::task::Poll::Ready(Err(std::io::Error::new(
|
|
std::io::ErrorKind::BrokenPipe,
|
|
"mock flush failure",
|
|
)));
|
|
}
|
|
std::task::Poll::Ready(Ok(()))
|
|
}
|
|
|
|
fn poll_close(
|
|
self: std::pin::Pin<&mut Self>,
|
|
cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<Result<(), Self::Error>> {
|
|
self.poll_flush(cx)
|
|
}
|
|
}
|
|
|
|
fn ordinary_disconnect_reason() -> watch::Receiver<Option<CommunityDisconnectReason>> {
|
|
let (_tx, rx) = watch::channel(None);
|
|
rx
|
|
}
|
|
|
|
fn deleted_community_disconnect_reason() -> watch::Receiver<Option<CommunityDisconnectReason>> {
|
|
let (tx, rx) = watch::channel(None);
|
|
tx.send_replace(Some(CommunityDisconnectReason::CommunityDeleted));
|
|
rx
|
|
}
|
|
|
|
fn text_payloads(messages: &[WsMessage]) -> Vec<String> {
|
|
messages
|
|
.iter()
|
|
.map(|msg| match msg {
|
|
WsMessage::Text(text) => text.to_string(),
|
|
other => panic!("unexpected websocket message in test: {other:?}"),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn req_rejections_are_subscription_scoped() {
|
|
let reason = "rate-limited: too many concurrent requests";
|
|
let closed: serde_json::Value =
|
|
serde_json::from_str(&request_rejection_message(Some("history-123"), reason))
|
|
.expect("parse CLOSED");
|
|
assert_eq!(closed, serde_json::json!(["CLOSED", "history-123", reason]));
|
|
|
|
let notice: serde_json::Value =
|
|
serde_json::from_str(&request_rejection_message(None, reason)).expect("parse NOTICE");
|
|
assert_eq!(notice, serde_json::json!(["NOTICE", reason]));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_batches_queued_data_frames_into_one_flush() {
|
|
let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH);
|
|
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
for i in 0..5 {
|
|
data_tx
|
|
.send(WsMessage::Text(format!("data-{i}").into()))
|
|
.await
|
|
.expect("queue data frame");
|
|
}
|
|
|
|
let (sink, state) = MockSink::new(Some(1));
|
|
let (_restart_tx, restart_rx) = mpsc::channel(1);
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
CancellationToken::new(),
|
|
ordinary_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(state.flush_count, 1);
|
|
assert_eq!(
|
|
text_payloads(&state.messages),
|
|
vec!["data-0", "data-1", "data-2", "data-3", "data-4"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_batch_one_preserves_single_frame_flush_behavior() {
|
|
let (data_tx, data_rx) = mpsc::channel(1);
|
|
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
data_tx
|
|
.send(WsMessage::Text("single".into()))
|
|
.await
|
|
.expect("queue data frame");
|
|
|
|
let (sink, state) = MockSink::new(Some(1));
|
|
let (_restart_tx, restart_rx) = mpsc::channel(1);
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
CancellationToken::new(),
|
|
ordinary_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(state.flush_count, 1);
|
|
assert_eq!(text_payloads(&state.messages), vec!["single"]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_drains_control_before_batched_data_without_reordering() {
|
|
let (data_tx, data_rx) = mpsc::channel(MAX_WS_SEND_BATCH);
|
|
let (ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
data_tx
|
|
.send(WsMessage::Text("data-0".into()))
|
|
.await
|
|
.expect("queue data frame");
|
|
data_tx
|
|
.send(WsMessage::Text("data-1".into()))
|
|
.await
|
|
.expect("queue data frame");
|
|
ctrl_tx
|
|
.send(WsMessage::Text("control".into()))
|
|
.await
|
|
.expect("queue control frame");
|
|
|
|
let (sink, state) = MockSink::new(Some(2));
|
|
let (_restart_tx, restart_rx) = mpsc::channel(1);
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
CancellationToken::new(),
|
|
ordinary_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(state.flush_count, 2);
|
|
assert_eq!(
|
|
text_payloads(&state.messages),
|
|
vec!["control", "data-0", "data-1"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_acknowledges_restart_after_flushing_exactly_one_1012() {
|
|
let (_data_tx, data_rx) = mpsc::channel(1);
|
|
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
let (restart_tx, restart_rx) = mpsc::channel(1);
|
|
let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel();
|
|
restart_tx
|
|
.send(RestartClose {
|
|
flushed: flushed_tx,
|
|
})
|
|
.await
|
|
.expect("queue restart close");
|
|
|
|
let (sink, state) = MockSink::new(None);
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
CancellationToken::new(),
|
|
ordinary_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(flushed_rx.await, Ok(true));
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(state.flush_count, 1, "ack follows the close flush");
|
|
assert_eq!(state.messages.len(), 1, "writer exits after restart close");
|
|
match &state.messages[0] {
|
|
WsMessage::Close(Some(close)) => {
|
|
assert_eq!(close.code, axum::extract::ws::close_code::RESTART);
|
|
assert_eq!(close.reason.as_str(), "relay restarting");
|
|
}
|
|
other => panic!("expected one 1012 restart close, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_reports_restart_flush_failure() {
|
|
let (_data_tx, data_rx) = mpsc::channel(1);
|
|
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
let (restart_tx, restart_rx) = mpsc::channel(1);
|
|
let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel();
|
|
restart_tx
|
|
.send(RestartClose {
|
|
flushed: flushed_tx,
|
|
})
|
|
.await
|
|
.expect("queue restart close");
|
|
|
|
let (sink, state) = MockSink::new(Some(1));
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
CancellationToken::new(),
|
|
ordinary_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(flushed_rx.await, Ok(false));
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(state.flush_count, 1);
|
|
assert_eq!(state.messages.len(), 1, "no fallback close is appended");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_sends_policy_close_when_community_is_deleted() {
|
|
let (_data_tx, data_rx) = mpsc::channel(1);
|
|
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
let (_restart_tx, restart_rx) = mpsc::channel(1);
|
|
let cancel = CancellationToken::new();
|
|
cancel.cancel();
|
|
|
|
let (sink, state) = MockSink::new(None);
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
cancel,
|
|
deleted_community_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(state.messages.len(), 1);
|
|
match &state.messages[0] {
|
|
WsMessage::Close(Some(close)) => {
|
|
assert_eq!(close.code, axum::extract::ws::close_code::POLICY);
|
|
assert_eq!(close.reason.as_str(), "community deleted");
|
|
}
|
|
other => panic!("expected one 1008 deletion close, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_sends_bare_close_for_ordinary_cancellation() {
|
|
let (_data_tx, data_rx) = mpsc::channel(1);
|
|
let (_ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
let (_restart_tx, restart_rx) = mpsc::channel(1);
|
|
let cancel = CancellationToken::new();
|
|
cancel.cancel();
|
|
|
|
let (sink, state) = MockSink::new(None);
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
cancel,
|
|
ordinary_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(state.messages.as_slice(), [WsMessage::Close(None)]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_loop_flushes_queued_control_before_close_on_cancel() {
|
|
// A ban disconnect queues its `OK false "blocked: …"` reason frame on
|
|
// the control channel and then cancels the token (B3). The biased
|
|
// select polls the cancel branch first, so the reason frame would be
|
|
// stranded unless the cancel branch drains ctrl before emitting Close.
|
|
// This test exercises `send_loop_inner` end-to-end to prove the reason
|
|
// frame reaches the client, in order, ahead of the Close.
|
|
let (_data_tx, data_rx) = mpsc::channel(1);
|
|
let (ctrl_tx, ctrl_rx) = mpsc::channel(1);
|
|
ctrl_tx
|
|
.send(WsMessage::Text("blocked: you are banned".into()))
|
|
.await
|
|
.expect("queue ban reason frame");
|
|
|
|
let cancel = CancellationToken::new();
|
|
cancel.cancel();
|
|
|
|
let (sink, state) = MockSink::new(None);
|
|
let (_restart_tx, restart_rx) = mpsc::channel(1);
|
|
send_loop_inner(
|
|
sink,
|
|
data_rx,
|
|
ctrl_rx,
|
|
restart_rx,
|
|
cancel,
|
|
ordinary_disconnect_reason(),
|
|
)
|
|
.await;
|
|
|
|
let state = state.lock().expect("mock sink poisoned");
|
|
assert_eq!(
|
|
state.messages.len(),
|
|
2,
|
|
"reason frame then Close, nothing else"
|
|
);
|
|
match &state.messages[0] {
|
|
WsMessage::Text(text) => {
|
|
assert_eq!(text.as_str(), "blocked: you are banned")
|
|
}
|
|
other => panic!("expected the ban reason frame first, got {other:?}"),
|
|
}
|
|
assert!(
|
|
matches!(state.messages[1], WsMessage::Close(None)),
|
|
"ordinary cancellation retains the bare Close after the reason frame"
|
|
);
|
|
}
|
|
}
|