Skip to content

Commit 3de3aa8

Browse files
loganjnpub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
authored andcommitted
fix(relay): cancel pending subscriptions on close
1 parent 8bb43d5 commit 3de3aa8

5 files changed

Lines changed: 508 additions & 35 deletions

File tree

crates/buzz-relay/src/connection.rs

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::time::Duration;
99
use axum::extract::ws::{Message as WsMessage, WebSocket};
1010
use futures_util::{Sink, SinkExt, StreamExt};
1111
use tokio::sync::{mpsc, Mutex, RwLock};
12+
use tokio::task::JoinSet;
1213
use tokio_util::sync::CancellationToken;
1314
use tracing::Instrument as _;
1415
use tracing::{debug, info, trace, warn};
@@ -29,6 +30,37 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
2930
/// Shared mutable subscription map for a single WebSocket connection.
3031
pub(crate) type ConnectionSubscriptions = Arc<Mutex<HashMap<String, Vec<Filter>>>>;
3132

33+
/// A cancellable lease for one in-flight REQ. The lease is installed before
34+
/// the handler task starts so CLOSE can invalidate a subscription that has not
35+
/// reached registration yet.
36+
#[derive(Debug)]
37+
pub(crate) struct PendingSubscription {
38+
cancel: CancellationToken,
39+
}
40+
41+
impl PendingSubscription {
42+
fn new() -> Self {
43+
Self {
44+
cancel: CancellationToken::new(),
45+
}
46+
}
47+
48+
pub(crate) fn cancel(&self) {
49+
self.cancel.cancel();
50+
}
51+
52+
pub(crate) fn is_cancelled(&self) -> bool {
53+
self.cancel.is_cancelled()
54+
}
55+
56+
pub(crate) async fn cancelled(&self) {
57+
self.cancel.cancelled().await;
58+
}
59+
}
60+
61+
/// In-flight REQs keyed by client-supplied subscription ID.
62+
pub(crate) type PendingSubscriptions = Arc<Mutex<HashMap<String, Arc<PendingSubscription>>>>;
63+
3264
/// Maximum outbound data frames buffered into the websocket sink before one flush.
3365
const MAX_WS_SEND_BATCH: usize = 64;
3466

@@ -63,6 +95,8 @@ pub struct ConnectionState {
6395
pub auth_state: RwLock<AuthState>,
6496
/// Active subscriptions keyed by subscription ID.
6597
pub subscriptions: ConnectionSubscriptions,
98+
/// REQs that have started but may not yet be registered for fan-out.
99+
pub(crate) pending_subscriptions: PendingSubscriptions,
66100
/// Sender for outbound data messages (EVENT, NOTICE, OK, etc.).
67101
pub send_tx: mpsc::Sender<WsMessage>,
68102
/// Sender for outbound control frames (Pong, Close).
@@ -80,6 +114,47 @@ pub struct ConnectionState {
80114
}
81115

82116
impl ConnectionState {
117+
/// Install a pending REQ before its handler task starts. A repeated REQ
118+
/// with the same subscription ID supersedes and cancels the older task.
119+
pub(crate) async fn begin_pending_subscription(
120+
&self,
121+
sub_id: &str,
122+
) -> Arc<PendingSubscription> {
123+
let pending = Arc::new(PendingSubscription::new());
124+
if let Some(replaced) = self
125+
.pending_subscriptions
126+
.lock()
127+
.await
128+
.insert(sub_id.to_owned(), Arc::clone(&pending))
129+
{
130+
replaced.cancel();
131+
}
132+
pending
133+
}
134+
135+
/// Cancel every in-flight REQ before connection registry cleanup begins.
136+
async fn cancel_all_pending_subscriptions(&self) {
137+
let mut pending = self.pending_subscriptions.lock().await;
138+
for (_, request) in pending.drain() {
139+
request.cancel();
140+
}
141+
}
142+
143+
/// Forget a completed REQ only if it still owns this subscription ID.
144+
async fn finish_pending_subscription(
145+
&self,
146+
sub_id: &str,
147+
completed: &Arc<PendingSubscription>,
148+
) {
149+
let mut pending = self.pending_subscriptions.lock().await;
150+
if pending
151+
.get(sub_id)
152+
.is_some_and(|current| Arc::ptr_eq(current, completed))
153+
{
154+
pending.remove(sub_id);
155+
}
156+
}
157+
83158
/// Sends a data message to this connection's outbound channel.
84159
///
85160
/// On a full buffer, increments the backpressure counter. The first
@@ -163,6 +238,7 @@ async fn handle_active_connection(
163238

164239
let backpressure_count = Arc::new(AtomicU8::new(0));
165240
let subscriptions = Arc::new(Mutex::new(HashMap::new()));
241+
let pending_subscriptions = Arc::new(Mutex::new(HashMap::new()));
166242

167243
let conn = Arc::new(ConnectionState {
168244
conn_id,
@@ -172,6 +248,7 @@ async fn handle_active_connection(
172248
challenge: challenge.clone(),
173249
}),
174250
subscriptions: Arc::clone(&subscriptions),
251+
pending_subscriptions,
175252
send_tx: tx.clone(),
176253
ctrl_tx: ctrl_tx.clone(),
177254
cancel: cancel.clone(),
@@ -412,6 +489,8 @@ async fn recv_loop(
412489
missed_pongs: Arc<AtomicU8>,
413490
cancel: CancellationToken,
414491
) {
492+
let mut req_tasks = JoinSet::new();
493+
415494
loop {
416495
tokio::select! {
417496
msg = ws_recv.next() => {
@@ -433,7 +512,12 @@ async fn recv_loop(
433512
break;
434513
}
435514
trace!(len = text.len(), "frame received");
436-
handle_text_message(text.to_string(), Arc::clone(&conn), Arc::clone(&state)).await;
515+
handle_text_message(
516+
text.to_string(),
517+
Arc::clone(&conn),
518+
Arc::clone(&state),
519+
&mut req_tasks,
520+
).await;
437521
}
438522
Some(Ok(WsMessage::Binary(bytes))) => {
439523
let max_frame_bytes = state.config.max_frame_bytes;
@@ -455,7 +539,12 @@ async fn recv_loop(
455539
// (notably certain Nostr libraries) send text payloads in binary frames.
456540
// NIP-01 is text-only, but accepting binary is a common relay extension.
457541
if let Ok(text) = String::from_utf8(bytes.to_vec()) {
458-
handle_text_message(text, Arc::clone(&conn), Arc::clone(&state)).await;
542+
handle_text_message(
543+
text,
544+
Arc::clone(&conn),
545+
Arc::clone(&state),
546+
&mut req_tasks,
547+
).await;
459548
}
460549
}
461550
Some(Ok(WsMessage::Pong(_))) => {
@@ -481,12 +570,31 @@ async fn recv_loop(
481570
}
482571
}
483572
}
573+
completed = req_tasks.join_next(), if !req_tasks.is_empty() => {
574+
if let Some(Err(error)) = completed {
575+
debug!(conn_id = %conn.conn_id, %error, "REQ handler task failed");
576+
}
577+
}
484578
_ = cancel.cancelled() => break,
485579
}
486580
}
581+
582+
shutdown_pending_requests(&conn, &mut req_tasks).await;
583+
}
584+
585+
pub(crate) async fn shutdown_pending_requests(conn: &ConnectionState, req_tasks: &mut JoinSet<()>) {
586+
conn.cancel.cancel();
587+
conn.cancel_all_pending_subscriptions().await;
588+
req_tasks.abort_all();
589+
while req_tasks.join_next().await.is_some() {}
487590
}
488591

489-
async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Arc<AppState>) {
592+
async fn handle_text_message(
593+
text: String,
594+
conn: Arc<ConnectionState>,
595+
state: Arc<AppState>,
596+
req_tasks: &mut JoinSet<()>,
597+
) {
490598
let msg = match ClientMessage::parse(&text) {
491599
Ok(m) => m,
492600
Err(e) => {
@@ -548,10 +656,22 @@ async fn handle_text_message(text: String, conn: Arc<ConnectionState>, state: Ar
548656
return;
549657
}
550658
};
659+
let pending = conn.begin_pending_subscription(&sub_id).await;
551660
let span = tracing::info_span!("ws.req", conn_id = %conn.conn_id, sub_id = %sub_id);
552-
tokio::spawn(
661+
req_tasks.spawn(
553662
async move {
554-
handlers::req::handle_req(sub_id, filters, conn, state).await;
663+
tokio::select! {
664+
biased;
665+
_ = pending.cancelled() => {}
666+
_ = handlers::req::handle_req(
667+
sub_id.clone(),
668+
filters,
669+
Arc::clone(&conn),
670+
state,
671+
Arc::clone(&pending),
672+
) => {}
673+
}
674+
conn.finish_pending_subscription(&sub_id, &pending).await;
555675
drop(permit);
556676
}
557677
.instrument(span),

crates/buzz-relay/src/handlers/close.rs

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::sync::Arc;
33
use tracing::debug;
44

55
use crate::connection::ConnectionState;
6+
use crate::handlers::req::SubscriptionTopics;
67
use crate::protocol::RelayMessage;
78
use crate::state::AppState;
89
use buzz_pubsub::EventTopic;
@@ -11,20 +12,41 @@ use buzz_pubsub::EventTopic;
1112
pub async fn handle_close(sub_id: String, conn: Arc<ConnectionState>, state: Arc<AppState>) {
1213
let conn_id = conn.conn_id;
1314

14-
conn.subscriptions.lock().await.remove(&sub_id);
15+
remove_subscription(&sub_id, &conn, &state.sub_registry, state.pubsub.as_ref()).await;
16+
17+
conn.send(RelayMessage::closed(&sub_id, ""));
18+
19+
debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription closed");
20+
}
21+
22+
/// Cancel an in-flight REQ or remove its committed subscription. The pending
23+
/// lock is held through topic release to serialize this cleanup with the REQ's
24+
/// registration/topic-retain commit.
25+
pub(crate) async fn remove_subscription(
26+
sub_id: &str,
27+
conn: &ConnectionState,
28+
registry: &crate::subscription::SubscriptionRegistry,
29+
pubsub: &dyn SubscriptionTopics,
30+
) {
31+
// Serialize CLOSE with the REQ registration/topic-retain commit. If the
32+
// REQ is still awaiting access checks, removing and cancelling its pending
33+
// lease makes the later registration check fail closed.
34+
let mut pending_subscriptions = conn.pending_subscriptions.lock().await;
35+
if let Some(pending) = pending_subscriptions.remove(sub_id) {
36+
pending.cancel();
37+
}
38+
39+
conn.subscriptions.lock().await.remove(sub_id);
1540

1641
// Deregister from the fan-out index before sending CLOSED so no new
1742
// messages are routed to this sub after the client's CLOSE is acknowledged.
18-
if let Some(removed) = state.sub_registry.remove_subscription(conn_id, &sub_id) {
19-
state
20-
.pubsub
43+
if let Some(removed) = registry.remove_subscription(conn.conn_id, sub_id) {
44+
pubsub
2145
.release_topic(&conn.tenant, topic_for_subscription(removed.channel_id))
2246
.await;
2347
}
2448

25-
conn.send(RelayMessage::closed(&sub_id, ""));
26-
27-
debug!(conn_id = %conn_id, sub_id = %sub_id, "Subscription closed");
49+
drop(pending_subscriptions);
2850
}
2951

3052
fn topic_for_subscription(channel_id: Option<uuid::Uuid>) -> EventTopic {

crates/buzz-relay/src/handlers/event.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1396,6 +1396,7 @@ mod tests {
13961396
},
13971397
)),
13981398
subscriptions: Arc::new(Mutex::new(HashMap::new())),
1399+
pending_subscriptions: Arc::new(Mutex::new(HashMap::new())),
13991400
send_tx,
14001401
ctrl_tx,
14011402
cancel: CancellationToken::new(),

0 commit comments

Comments
 (0)