mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
fix(relay): send REQ frames only while the request owns its ID
REQ handlers run in separate tasks, so a same-ID REQ can replace one that is still reading. The replaced history, search or huddle-liveness request then sent its EVENT and EOSE frames anyway. When the new holder is an artifact sync, that stale EOSE tells the client a set the sync never sent is complete. These frames now go through close::send_if_owner, which queues a frame only while the request still owns the ID, under the lifecycle lock. A subscription-limit refusal (ordinary REQ and sync) now cancels the connection when its CLOSED cannot be queued, as close::refuse does. The ID is new there, so there is no old holder to retire. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -2313,6 +2313,39 @@ pub(crate) mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A REQ for a new ID refused at the subscription limit cancels the
|
||||||
|
/// connection when its `CLOSED` cannot be queued: the client would
|
||||||
|
/// otherwise wait on a request the relay refused.
|
||||||
|
/// Mutation: ignore the `send` result on that refusal → RED.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn subscription_limit_refusal_with_a_full_queue_cancels_the_connection() {
|
||||||
|
let state = crate::state::tests::test_state().await;
|
||||||
|
let (conn, _rx) = test_conn_with_auth(authenticated_state());
|
||||||
|
{
|
||||||
|
let mut subs = conn.subscriptions.lock().await;
|
||||||
|
for index in 0..handlers::req::MAX_SUBSCRIPTIONS {
|
||||||
|
subs.insert(format!("held-{index}"), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while conn
|
||||||
|
.send_tx
|
||||||
|
.try_send(WsMessage::Text("filler".into()))
|
||||||
|
.is_ok()
|
||||||
|
{}
|
||||||
|
|
||||||
|
handlers::req::handle_req(
|
||||||
|
"s".into(),
|
||||||
|
vec![nostr::Filter::new().kind(Kind::TextNote)],
|
||||||
|
vec![None],
|
||||||
|
Arc::clone(&conn),
|
||||||
|
Arc::clone(&state),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(conn.cancel.is_cancelled());
|
||||||
|
assert!(!conn.subscriptions.lock().await.contains_key("s"));
|
||||||
|
}
|
||||||
|
|
||||||
/// A pre-handler refusal whose `CLOSED` cannot be queued has already
|
/// A pre-handler refusal whose `CLOSED` cannot be queued has already
|
||||||
/// retired the old holder, so it cancels the connection: the client
|
/// retired the old holder, so it cancels the connection: the client
|
||||||
/// learns the ID is gone from the disconnect. The queue is full but
|
/// learns the ID is gone from the disconnect. The queue is full but
|
||||||
|
|||||||
@@ -466,10 +466,14 @@ async fn claim(
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if !subs.contains_key(sub_id) && subs.len() >= super::req::MAX_SUBSCRIPTIONS {
|
if !subs.contains_key(sub_id) && subs.len() >= super::req::MAX_SUBSCRIPTIONS {
|
||||||
conn.send(RelayMessage::closed(
|
// A new ID has no old holder to retire, but a lost CLOSED would leave
|
||||||
|
// the client waiting on a refused sync.
|
||||||
|
if !conn.send(RelayMessage::closed(
|
||||||
sub_id,
|
sub_id,
|
||||||
"error: too many subscriptions",
|
"error: too many subscriptions",
|
||||||
));
|
)) {
|
||||||
|
conn.cancel.cancel();
|
||||||
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
super::close::retire_locked(&mut subs, sub_id, conn, state).await;
|
super::close::retire_locked(&mut subs, sub_id, conn, state).await;
|
||||||
|
|||||||
@@ -621,3 +621,73 @@ async fn refused_or_one_shot_replacement_retires_the_old_holder() {
|
|||||||
no_frame(&mut rx).await;
|
no_frame(&mut rx).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A same-ID sync that replaces a history, search or huddle-liveness `REQ`
|
||||||
|
/// while that request waits to send gets only its own frames: the old
|
||||||
|
/// request's late frames, `EOSE` included, must not reach the sync's
|
||||||
|
/// client. The old request pauses just before its first frame.
|
||||||
|
/// Mutation: drop the owner check in `close::send_if_owner` → stale frames
|
||||||
|
/// follow the sync's `EOSE` → RED.
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires Postgres and Redis"]
|
||||||
|
async fn a_replaced_req_sends_nothing_under_a_sync_that_took_its_id() {
|
||||||
|
use super::close::test_seam::{Pause, SEND_PAUSE};
|
||||||
|
let f = fixture().await;
|
||||||
|
let owner = Keys::generate();
|
||||||
|
let history: fn(Uuid) -> Vec<Value> = |home| vec![json!({"kinds": [45010], "#h": [home]})];
|
||||||
|
let search: fn(Uuid) -> Vec<Value> = |home| vec![json!({"search": "x", "#h": [home]})];
|
||||||
|
let liveness: fn(Uuid) -> Vec<Value> =
|
||||||
|
|home| vec![json!({"kinds": [buzz_core::kind::KIND_HUDDLE_LIVENESS], "#h": [home]})];
|
||||||
|
for (name, old_req) in [
|
||||||
|
("history", history),
|
||||||
|
("search", search),
|
||||||
|
("liveness", liveness),
|
||||||
|
] {
|
||||||
|
let home = create_channel(&f.state, &f.tenant, &owner, &[]).await;
|
||||||
|
let created = revision(&f, &owner, home, Uuid::new_v4(), "create", None).await;
|
||||||
|
let (conn, mut rx) = connect(&f, &owner);
|
||||||
|
let pause = Arc::new(Pause::default());
|
||||||
|
let stale = tokio::spawn({
|
||||||
|
let (f_conn, state) = (Arc::clone(&conn), Arc::clone(&f.state));
|
||||||
|
let filters: Vec<nostr::Filter> = old_req(home)
|
||||||
|
.into_iter()
|
||||||
|
.map(|filter| serde_json::from_value(filter).expect("filter"))
|
||||||
|
.collect();
|
||||||
|
let before_ids = vec![None; filters.len()];
|
||||||
|
SEND_PAUSE.scope(
|
||||||
|
Arc::clone(&pause),
|
||||||
|
super::req::handle_req("s".into(), filters, before_ids, f_conn, state),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), pause.reached.notified())
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("{name}: the old REQ reaches its first send"));
|
||||||
|
|
||||||
|
let sync = start_sync(&f, &conn, sync_filter(&[home]));
|
||||||
|
assert_eq!(
|
||||||
|
initial_set(&mut rx).await,
|
||||||
|
vec![created.id.to_hex()],
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
// The seam pauses before every send; release each one.
|
||||||
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
|
while !stale.is_finished() {
|
||||||
|
pause.resume.notify_one();
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("{name}: the old REQ ends"));
|
||||||
|
stale.await.unwrap();
|
||||||
|
no_frame(&mut rx).await;
|
||||||
|
assert!(
|
||||||
|
f.state.artifact_syncs.contains(conn.conn_id, "s"),
|
||||||
|
"{name}: the sync still holds the ID"
|
||||||
|
);
|
||||||
|
super::close::handle_close("s".into(), Arc::clone(&conn), Arc::clone(&f.state)).await;
|
||||||
|
tokio::time::timeout(Duration::from_secs(5), sync)
|
||||||
|
.await
|
||||||
|
.expect("sync task ends on CLOSE")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ pub(crate) async fn refuse(sub_id: &str, reason: &str, conn: &ConnectionState, s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Queue `frame` for `sub_id` only while the request holding `owner` still
|
||||||
|
/// owns the ID. A request that a newer same-ID REQ replaced sends nothing:
|
||||||
|
/// its late `EOSE` would otherwise tell a replacing sync that a set it never
|
||||||
|
/// sent is complete. Returns whether the frame was queued.
|
||||||
|
pub(crate) async fn send_if_owner(
|
||||||
|
sub_id: &str,
|
||||||
|
owner: u64,
|
||||||
|
frame: String,
|
||||||
|
conn: &ConnectionState,
|
||||||
|
) -> bool {
|
||||||
|
#[cfg(test)]
|
||||||
|
test_seam::pause_at(&test_seam::SEND_PAUSE).await;
|
||||||
|
let subs = conn.subscriptions.lock().await;
|
||||||
|
subs.get(sub_id) == Some(&owner) && conn.send(frame)
|
||||||
|
}
|
||||||
|
|
||||||
/// Monotonic source of subscription owner tokens. Each REQ claims its sub ID
|
/// Monotonic source of subscription owner tokens. Each REQ claims its sub ID
|
||||||
/// with a fresh token, so a superseded request can tell it no longer owns it.
|
/// with a fresh token, so a superseded request can tell it no longer owns it.
|
||||||
static NEXT_OWNER: AtomicU64 = AtomicU64::new(1);
|
static NEXT_OWNER: AtomicU64 = AtomicU64::new(1);
|
||||||
@@ -151,6 +167,7 @@ pub(crate) mod test_seam {
|
|||||||
pub(crate) static PAUSE: Arc<Pause>;
|
pub(crate) static PAUSE: Arc<Pause>;
|
||||||
pub(crate) static RELEASE_PAUSE: Arc<Pause>;
|
pub(crate) static RELEASE_PAUSE: Arc<Pause>;
|
||||||
pub(crate) static CLEANUP_PAUSE: Arc<Pause>;
|
pub(crate) static CLEANUP_PAUSE: Arc<Pause>;
|
||||||
|
pub(crate) static SEND_PAUSE: Arc<Pause>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn pause_at(point: &'static tokio::task::LocalKey<Arc<Pause>>) {
|
pub(crate) async fn pause_at(point: &'static tokio::task::LocalKey<Arc<Pause>>) {
|
||||||
|
|||||||
@@ -74,10 +74,14 @@ pub async fn handle_req(
|
|||||||
|
|
||||||
let subs = conn.subscriptions.lock().await;
|
let subs = conn.subscriptions.lock().await;
|
||||||
if !subs.contains_key(&sub_id) && subs.len() >= MAX_SUBSCRIPTIONS {
|
if !subs.contains_key(&sub_id) && subs.len() >= MAX_SUBSCRIPTIONS {
|
||||||
conn.send(RelayMessage::closed(
|
// A new ID has no old holder to retire, but a lost CLOSED
|
||||||
|
// would leave the client waiting on a refused request.
|
||||||
|
if !conn.send(RelayMessage::closed(
|
||||||
&sub_id,
|
&sub_id,
|
||||||
"error: too many subscriptions",
|
"error: too many subscriptions",
|
||||||
));
|
)) {
|
||||||
|
conn.cancel.cancel();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +503,8 @@ pub async fn handle_req(
|
|||||||
if e.is_statement_cancelled() {
|
if e.is_statement_cancelled() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
conn.send(RelayMessage::eose(&sub_id));
|
super::close::send_if_owner(&sub_id, owner, RelayMessage::eose(&sub_id), &conn)
|
||||||
|
.await;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -576,7 +581,7 @@ pub async fn handle_req(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let msg = RelayMessage::event(&sub_id, &stored.event);
|
let msg = RelayMessage::event(&sub_id, &stored.event);
|
||||||
if !conn.send(msg) {
|
if !super::close::send_if_owner(&sub_id, owner, msg, &conn).await {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
total_sent += 1;
|
total_sent += 1;
|
||||||
@@ -586,7 +591,7 @@ pub async fn handle_req(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.send(RelayMessage::eose(&sub_id));
|
super::close::send_if_owner(&sub_id, owner, RelayMessage::eose(&sub_id), &conn).await;
|
||||||
|
|
||||||
debug!(
|
debug!(
|
||||||
conn_id = %conn_id,
|
conn_id = %conn_id,
|
||||||
@@ -784,7 +789,7 @@ async fn handle_search_req(
|
|||||||
if build_search_channel_scope_filter(implicit_channels, include_global).is_none()
|
if build_search_channel_scope_filter(implicit_channels, include_global).is_none()
|
||||||
&& named_only.is_empty()
|
&& named_only.is_empty()
|
||||||
{
|
{
|
||||||
conn.send(RelayMessage::eose(sub_id));
|
super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,7 +993,8 @@ async fn handle_search_req(
|
|||||||
if !seen_ids.insert(stored.event.id) {
|
if !seen_ids.insert(stored.event.id) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if !conn.send(RelayMessage::event(sub_id, &stored.event)) {
|
let frame = RelayMessage::event(sub_id, &stored.event);
|
||||||
|
if !super::close::send_if_owner(sub_id, owner, frame, conn).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emitted += 1;
|
emitted += 1;
|
||||||
@@ -1001,7 +1007,7 @@ async fn handle_search_req(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.send(RelayMessage::eose(sub_id));
|
super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a single NIP-01 filter into an [`EventQuery`] for the database.
|
/// Convert a single NIP-01 filter into an [`EventQuery`] for the database.
|
||||||
@@ -1526,12 +1532,13 @@ async fn handle_huddle_liveness_req(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !conn.send(RelayMessage::event(sub_id, &event)) {
|
let frame = RelayMessage::event(sub_id, &event);
|
||||||
|
if !super::close::send_if_owner(sub_id, owner, frame, conn).await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conn.send(RelayMessage::eose(sub_id));
|
super::close::send_if_owner(sub_id, owner, RelayMessage::eose(sub_id), conn).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Claim `sub_id` for a live REQ under the lifecycle lock: record a fresh
|
/// Claim `sub_id` for a live REQ under the lifecycle lock: record a fresh
|
||||||
|
|||||||
Reference in New Issue
Block a user