mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
fix(buzz): prevent reconnect storms from reaped ephemeral channels (#1071)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -85,6 +85,81 @@ pub struct ChannelInfo {
|
|||||||
pub channel_type: String,
|
pub channel_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the discovered-channel subscribe set from the membership UUIDs and the
|
||||||
|
/// kind:39000 metadata events, **skipping any channel flagged `archived=true`**.
|
||||||
|
///
|
||||||
|
/// Archived channels (e.g. auto-archived by the ephemeral-channel reaper) are
|
||||||
|
/// unusable: re-offering one on reconnect draws a `CLOSED restricted` and would
|
||||||
|
/// re-form the reconnect loop. Dropping them here is the defense-in-depth
|
||||||
|
/// backstop to the relay-side live-subscription eviction — it covers a client
|
||||||
|
/// that was offline when the channel was reaped and so missed the CLOSED.
|
||||||
|
/// A channel with no metadata event defaults to a `stream` named `unknown`,
|
||||||
|
/// preserving prior behavior for non-archived channels.
|
||||||
|
fn merge_discovered_channels(
|
||||||
|
channel_uuids: Vec<Uuid>,
|
||||||
|
meta_events: &serde_json::Value,
|
||||||
|
) -> HashMap<Uuid, ChannelInfo> {
|
||||||
|
let mut meta_map: HashMap<Uuid, (String, String)> = HashMap::new();
|
||||||
|
let mut archived: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
|
||||||
|
if let Some(arr) = meta_events.as_array() {
|
||||||
|
for ev in arr {
|
||||||
|
let tags = match ev.get("tags").and_then(|t| t.as_array()) {
|
||||||
|
Some(t) => t,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
let mut d_val = None;
|
||||||
|
let mut name = None;
|
||||||
|
let mut is_hidden = false;
|
||||||
|
let mut is_private = false;
|
||||||
|
let mut is_archived = false;
|
||||||
|
for tag in tags {
|
||||||
|
if let Some(arr) = tag.as_array() {
|
||||||
|
match arr.first().and_then(|v| v.as_str()) {
|
||||||
|
Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()),
|
||||||
|
Some("name") => name = arr.get(1).and_then(|v| v.as_str()),
|
||||||
|
Some("hidden") => is_hidden = true,
|
||||||
|
Some("private") => is_private = true,
|
||||||
|
Some("archived") => {
|
||||||
|
is_archived = arr.get(1).and_then(|v| v.as_str()) == Some("true")
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(d) = d_val {
|
||||||
|
if let Ok(uuid) = d.parse::<Uuid>() {
|
||||||
|
if is_archived {
|
||||||
|
archived.insert(uuid);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ch_name = name.unwrap_or("unknown").to_string();
|
||||||
|
// DMs have the "hidden" tag; private channels have "private".
|
||||||
|
let ch_type = if is_hidden {
|
||||||
|
"dm".to_string()
|
||||||
|
} else if is_private {
|
||||||
|
"private".to_string()
|
||||||
|
} else {
|
||||||
|
"stream".to_string()
|
||||||
|
};
|
||||||
|
meta_map.insert(uuid, (ch_name, ch_type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut map = HashMap::with_capacity(channel_uuids.len());
|
||||||
|
for uuid in channel_uuids {
|
||||||
|
if archived.contains(&uuid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (name, channel_type) = meta_map
|
||||||
|
.remove(&uuid)
|
||||||
|
.unwrap_or_else(|| ("unknown".to_string(), "stream".to_string()));
|
||||||
|
map.insert(uuid, ChannelInfo { name, channel_type });
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
/// Lightweight HTTP client for pre-prompt context fetches via the Nostr HTTP bridge.
|
/// Lightweight HTTP client for pre-prompt context fetches via the Nostr HTTP bridge.
|
||||||
///
|
///
|
||||||
/// Extracted from `HarnessRelay` fields so it can be shared (via `Arc`) with
|
/// Extracted from `HarnessRelay` fields so it can be shared (via `Arc`) with
|
||||||
@@ -565,54 +640,8 @@ impl HarnessRelay {
|
|||||||
.custom_tags(d_tag, d_values);
|
.custom_tags(d_tag, d_values);
|
||||||
let meta_events = rest.query(&[meta_filter]).await?;
|
let meta_events = rest.query(&[meta_filter]).await?;
|
||||||
|
|
||||||
// Build UUID → (name, channel_type) from metadata events.
|
// Step 3: Build the final subscribe set, skipping archived channels.
|
||||||
let mut meta_map: HashMap<Uuid, (String, String)> = HashMap::new();
|
let map = merge_discovered_channels(channel_uuids, &meta_events);
|
||||||
if let Some(arr) = meta_events.as_array() {
|
|
||||||
for ev in arr {
|
|
||||||
let tags = match ev.get("tags").and_then(|t| t.as_array()) {
|
|
||||||
Some(t) => t,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let mut d_val = None;
|
|
||||||
let mut name = None;
|
|
||||||
let mut is_hidden = false;
|
|
||||||
let mut is_private = false;
|
|
||||||
for tag in tags {
|
|
||||||
if let Some(arr) = tag.as_array() {
|
|
||||||
match arr.first().and_then(|v| v.as_str()) {
|
|
||||||
Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()),
|
|
||||||
Some("name") => name = arr.get(1).and_then(|v| v.as_str()),
|
|
||||||
Some("hidden") => is_hidden = true,
|
|
||||||
Some("private") => is_private = true,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(d) = d_val {
|
|
||||||
if let Ok(uuid) = d.parse::<Uuid>() {
|
|
||||||
let ch_name = name.unwrap_or("unknown").to_string();
|
|
||||||
// DMs have the "hidden" tag; private channels have "private".
|
|
||||||
let ch_type = if is_hidden {
|
|
||||||
"dm".to_string()
|
|
||||||
} else if is_private {
|
|
||||||
"private".to_string()
|
|
||||||
} else {
|
|
||||||
"stream".to_string()
|
|
||||||
};
|
|
||||||
meta_map.insert(uuid, (ch_name, ch_type));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Merge into final map.
|
|
||||||
let mut map = HashMap::with_capacity(channel_uuids.len());
|
|
||||||
for uuid in channel_uuids {
|
|
||||||
let (name, channel_type) = meta_map
|
|
||||||
.remove(&uuid)
|
|
||||||
.unwrap_or_else(|| ("unknown".to_string(), "stream".to_string()));
|
|
||||||
map.insert(uuid, ChannelInfo { name, channel_type });
|
|
||||||
}
|
|
||||||
|
|
||||||
debug!("discovered {} channel(s)", map.len());
|
debug!("discovered {} channel(s)", map.len());
|
||||||
Ok(map)
|
Ok(map)
|
||||||
@@ -1748,6 +1777,15 @@ async fn handle_ws_message(
|
|||||||
subscription_id,
|
subscription_id,
|
||||||
message,
|
message,
|
||||||
} => {
|
} => {
|
||||||
|
// A per-channel membership denial means THIS channel is
|
||||||
|
// forbidden, not the whole connection. Drop just this
|
||||||
|
// channel's subscription and keep the socket — otherwise the
|
||||||
|
// socket is torn down, the forbidden channel is resubscribed,
|
||||||
|
// and the same CLOSED arrives again: a tight reconnect loop.
|
||||||
|
if drop_channel_on_access_denied(state, &subscription_id, &message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Finding #15: CLOSED needs cleanup and resubscribe, not just logging.
|
// Finding #15: CLOSED needs cleanup and resubscribe, not just logging.
|
||||||
// Classify the error to decide how to respond.
|
// Classify the error to decide how to respond.
|
||||||
let is_auth_error = message.starts_with("auth-required")
|
let is_auth_error = message.starts_with("auth-required")
|
||||||
@@ -2565,6 +2603,46 @@ fn channel_id_from_sub_id(sub_id: &str) -> Option<Uuid> {
|
|||||||
.and_then(|s| s.parse::<Uuid>().ok())
|
.and_then(|s| s.parse::<Uuid>().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-channel CLOSED denials: the channel is forbidden but the connection is
|
||||||
|
/// fine. Match these EXACT strings, never a `starts_with("restricted")` prefix —
|
||||||
|
/// a prefix would also swallow connection-level `restricted: insufficient scope`,
|
||||||
|
/// dropping a channel instead of reconnecting. The only CLOSED senders of these
|
||||||
|
/// strings are `req.rs:153` (not a channel member) and `side_effects.rs:71`
|
||||||
|
/// (channel access revoked, via member eviction / open→private flip).
|
||||||
|
/// `ingest.rs` returns these as EVENT-publish `OK(false)`, never as a
|
||||||
|
/// subscription CLOSED, so it is not a source here.
|
||||||
|
const CHANNEL_ACCESS_DENIED_REASONS: &[&str] = &[
|
||||||
|
"restricted: not a channel member",
|
||||||
|
"restricted: channel access revoked",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Handle a CLOSED that denies access to a single channel: drop just that
|
||||||
|
/// channel's subscription (the proven Unsubscribe cleanup) and keep the socket.
|
||||||
|
///
|
||||||
|
/// Returns `true` when the CLOSED was an exact per-channel denial on a `ch-`
|
||||||
|
/// subscription and the channel was dropped — the caller keeps the connection
|
||||||
|
/// with no reconnect. Returns `false` for everything else (connection-level
|
||||||
|
/// `restricted: insufficient scope`, `auth-required`, non-channel subs), which
|
||||||
|
/// falls through to the existing reconnect path.
|
||||||
|
///
|
||||||
|
/// An already-removed channel is a harmless no-op: the remove/clear simply
|
||||||
|
/// affect nothing, and the dropped channel is never re-subscribed, so the loop
|
||||||
|
/// cannot re-form.
|
||||||
|
fn drop_channel_on_access_denied(state: &mut BgState, sub_id: &str, message: &str) -> bool {
|
||||||
|
if !CHANNEL_ACCESS_DENIED_REASONS.contains(&message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(channel_id) = channel_id_from_sub_id(sub_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
warn!(
|
||||||
|
"channel {channel_id} access denied by relay: {message} — dropping subscription, keeping connection"
|
||||||
|
);
|
||||||
|
state.active_subscriptions.remove(&channel_id);
|
||||||
|
state.clear_channel_state(&channel_id);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply the appropriate auth header to a reqwest request builder.
|
/// Apply the appropriate auth header to a reqwest request builder.
|
||||||
/// Parse a raw relay text frame into a typed [`RelayMessage`].
|
/// Parse a raw relay text frame into a typed [`RelayMessage`].
|
||||||
#[allow(private_interfaces)]
|
#[allow(private_interfaces)]
|
||||||
@@ -2918,6 +2996,73 @@ mod tests {
|
|||||||
assert!(channel_id_from_sub_id("").is_none());
|
assert!(channel_id_from_sub_id("").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── merge_discovered_channels (archived skip) ─────────────────────────────
|
||||||
|
|
||||||
|
fn meta_event(uuid: Uuid, name: &str, extra: &[&str]) -> serde_json::Value {
|
||||||
|
let mut tags = vec![
|
||||||
|
serde_json::json!(["d", uuid.to_string()]),
|
||||||
|
serde_json::json!(["name", name]),
|
||||||
|
];
|
||||||
|
// `extra` is a flat list of single-value tag names (e.g. archived=true).
|
||||||
|
for pair in extra.chunks(2) {
|
||||||
|
match pair {
|
||||||
|
[k, v] => tags.push(serde_json::json!([k, v])),
|
||||||
|
[k] => tags.push(serde_json::json!([k])),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::json!({ "tags": tags })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_discovered_channels_skips_archived_metadata() {
|
||||||
|
let live = Uuid::new_v4();
|
||||||
|
let archived = Uuid::new_v4();
|
||||||
|
let meta = serde_json::json!([
|
||||||
|
meta_event(live, "live", &[]),
|
||||||
|
meta_event(archived, "dead", &["archived", "true"]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let map = merge_discovered_channels(vec![live, archived], &meta);
|
||||||
|
|
||||||
|
assert!(map.contains_key(&live), "non-archived channel is kept");
|
||||||
|
assert!(
|
||||||
|
!map.contains_key(&archived),
|
||||||
|
"archived=true channel is skipped from the subscribe set"
|
||||||
|
);
|
||||||
|
assert_eq!(map.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_discovered_channels_skips_archived_even_when_still_a_member() {
|
||||||
|
// The offline feeder: the agent is still listed as a member
|
||||||
|
// (uuid present in channel_uuids, the kind:39002 membership set), but the
|
||||||
|
// channel was reaped while the agent was offline. Even though the agent
|
||||||
|
// missed the eviction CLOSED, the archived=true kind:39000 makes the
|
||||||
|
// client skip re-subscribing on reconnect — proving (b) closes the loop
|
||||||
|
// independently of the relay-side eviction.
|
||||||
|
let reaped = Uuid::new_v4();
|
||||||
|
let meta = serde_json::json!([meta_event(reaped, "reaped", &["archived", "true"])]);
|
||||||
|
|
||||||
|
let map = merge_discovered_channels(vec![reaped], &meta);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
map.is_empty(),
|
||||||
|
"a still-member but archived channel is not re-subscribed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_discovered_channels_archived_false_is_kept() {
|
||||||
|
// An explicit archived=false (e.g. after unarchive) must NOT be skipped.
|
||||||
|
let ch = Uuid::new_v4();
|
||||||
|
let meta = serde_json::json!([meta_event(ch, "back", &["archived", "false"])]);
|
||||||
|
|
||||||
|
let map = merge_discovered_channels(vec![ch], &meta);
|
||||||
|
|
||||||
|
assert!(map.contains_key(&ch), "archived=false is treated as live");
|
||||||
|
}
|
||||||
|
|
||||||
// ── parse_relay_message ───────────────────────────────────────────────────
|
// ── parse_relay_message ───────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3478,4 +3623,147 @@ mod tests {
|
|||||||
"after backpressure removal, replay must be accepted"
|
"after backpressure removal, replay must be accepted"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── drop_channel_on_access_denied (Phenomenon A: reconnect loop) ──────────
|
||||||
|
|
||||||
|
/// Subscribe a channel via the production command path so the test exercises
|
||||||
|
/// real subscription state (active_subscriptions + active_filters + since).
|
||||||
|
fn subscribe_channel(state: &mut BgState, channel_id: Uuid) {
|
||||||
|
apply_command_to_state(
|
||||||
|
state,
|
||||||
|
RelayCommand::Subscribe {
|
||||||
|
channel_id,
|
||||||
|
filter: ChannelFilter {
|
||||||
|
kinds: Some(vec![9]),
|
||||||
|
require_mention: false,
|
||||||
|
},
|
||||||
|
replay_since: Some(1_000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn not_a_channel_member_drops_channel_without_reconnect() {
|
||||||
|
let mut state = BgState::new();
|
||||||
|
let channel_id = Uuid::new_v4();
|
||||||
|
subscribe_channel(&mut state, channel_id);
|
||||||
|
|
||||||
|
let handled = drop_channel_on_access_denied(
|
||||||
|
&mut state,
|
||||||
|
&channel_sub_id(channel_id),
|
||||||
|
"restricted: not a channel member",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(handled, "per-channel denial must be handled (no reconnect)");
|
||||||
|
assert!(
|
||||||
|
!state.active_subscriptions.contains_key(&channel_id),
|
||||||
|
"the forbidden channel's subscription must be dropped"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.active_filters.contains_key(&channel_id),
|
||||||
|
"channel state must be cleared (Unsubscribe cleanup)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_access_revoked_drops_channel_without_reconnect() {
|
||||||
|
let mut state = BgState::new();
|
||||||
|
let channel_id = Uuid::new_v4();
|
||||||
|
subscribe_channel(&mut state, channel_id);
|
||||||
|
|
||||||
|
let handled = drop_channel_on_access_denied(
|
||||||
|
&mut state,
|
||||||
|
&channel_sub_id(channel_id),
|
||||||
|
"restricted: channel access revoked",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(handled, "per-channel denial must be handled (no reconnect)");
|
||||||
|
assert!(!state.active_subscriptions.contains_key(&channel_id));
|
||||||
|
assert!(!state.active_filters.contains_key(&channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insufficient_scope_is_not_dropped_and_reconnects() {
|
||||||
|
let mut state = BgState::new();
|
||||||
|
let channel_id = Uuid::new_v4();
|
||||||
|
subscribe_channel(&mut state, channel_id);
|
||||||
|
|
||||||
|
let handled = drop_channel_on_access_denied(
|
||||||
|
&mut state,
|
||||||
|
&channel_sub_id(channel_id),
|
||||||
|
"restricted: insufficient scope",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!handled,
|
||||||
|
"connection-level insufficient-scope must fall through to reconnect, not drop the channel"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
state.active_subscriptions.contains_key(&channel_id),
|
||||||
|
"the channel must survive so reconnect can restore it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_required_is_not_dropped_and_reconnects() {
|
||||||
|
let mut state = BgState::new();
|
||||||
|
let channel_id = Uuid::new_v4();
|
||||||
|
subscribe_channel(&mut state, channel_id);
|
||||||
|
|
||||||
|
let handled = drop_channel_on_access_denied(
|
||||||
|
&mut state,
|
||||||
|
&channel_sub_id(channel_id),
|
||||||
|
"auth-required: not authenticated",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!handled,
|
||||||
|
"auth-required must fall through to reconnect, not drop the channel"
|
||||||
|
);
|
||||||
|
assert!(state.active_subscriptions.contains_key(&channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn already_removed_channel_is_a_no_op() {
|
||||||
|
let mut state = BgState::new();
|
||||||
|
let channel_id = Uuid::new_v4();
|
||||||
|
// Channel was never subscribed (or already dropped) — a delayed CLOSED.
|
||||||
|
|
||||||
|
let handled = drop_channel_on_access_denied(
|
||||||
|
&mut state,
|
||||||
|
&channel_sub_id(channel_id),
|
||||||
|
"restricted: not a channel member",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
handled,
|
||||||
|
"an exact per-channel denial is still handled (keep socket) even if the channel is gone"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.active_subscriptions.contains_key(&channel_id),
|
||||||
|
"no-op: nothing to remove and nothing resurrected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropped_channel_is_not_resubscribed_so_loop_cannot_re_form() {
|
||||||
|
let mut state = BgState::new();
|
||||||
|
let channel_id = Uuid::new_v4();
|
||||||
|
subscribe_channel(&mut state, channel_id);
|
||||||
|
|
||||||
|
drop_channel_on_access_denied(
|
||||||
|
&mut state,
|
||||||
|
&channel_sub_id(channel_id),
|
||||||
|
"restricted: not a channel member",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate a reconnect: only channels still in active_subscriptions are
|
||||||
|
// restored. The dropped channel must not be among them — otherwise the
|
||||||
|
// forbidden channel would be resubscribed and earn the same CLOSED again.
|
||||||
|
let resubscribed: Vec<Uuid> = state.active_subscriptions.keys().copied().collect();
|
||||||
|
assert!(
|
||||||
|
!resubscribed.contains(&channel_id),
|
||||||
|
"the dropped channel must not be resubscribed — the loop cannot re-form"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,21 @@ async fn evict_non_member_channel_subscriptions(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close every live subscription on a channel, for all subscribers, sending
|
||||||
|
/// `CLOSED restricted: channel access revoked` to each.
|
||||||
|
///
|
||||||
|
/// Used when a channel is archived (e.g. the ephemeral-channel reaper): the
|
||||||
|
/// channel becomes unusable for everyone, so all live subscriptions must close.
|
||||||
|
/// The `channel access revoked` reason is in the client's drop-set, so a
|
||||||
|
/// connected agent drops just that channel and keeps its socket — no reconnect
|
||||||
|
/// storm. Offline/reconnecting clients are covered by the discovery-time
|
||||||
|
/// `archived=true` skip in `discover_channels`.
|
||||||
|
pub async fn evict_all_channel_subscriptions(state: &Arc<AppState>, channel_id: Uuid) {
|
||||||
|
for conn_id in state.sub_registry.channel_subscriber_conns(channel_id) {
|
||||||
|
evict_conn_channel_subscriptions(state, channel_id, conn_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Dispatch side effects for a stored event.
|
/// Dispatch side effects for a stored event.
|
||||||
pub async fn handle_side_effects(
|
pub async fn handle_side_effects(
|
||||||
kind: u32,
|
kind: u32,
|
||||||
|
|||||||
@@ -389,6 +389,16 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
{
|
{
|
||||||
error!(channel = %channel_id, "reaper discovery update failed: {e}");
|
error!(channel = %channel_id, "reaper discovery update failed: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close live subscriptions so connected clients drop the
|
||||||
|
// archived channel immediately (CLOSED is in the client's
|
||||||
|
// drop-set → no reconnect storm). Offline clients are caught
|
||||||
|
// by the archived=true skip in discover_channels on reconnect.
|
||||||
|
buzz_relay::handlers::side_effects::evict_all_channel_subscriptions(
|
||||||
|
&reaper_state,
|
||||||
|
*channel_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -987,6 +987,51 @@ mod tests {
|
|||||||
assert_eq!(conns, expected);
|
assert_eq!(conns, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_evict_all_subscribers_leaves_no_channel_subscriptions() {
|
||||||
|
// Models the reaper's evict_all_channel_subscriptions: remove every
|
||||||
|
// subscriber conn of a channel, leaving the channel with zero live subs
|
||||||
|
// while an unrelated channel is untouched. This is the registry behavior
|
||||||
|
// that lets an auto-archived channel stop fanning out without a storm.
|
||||||
|
let registry = SubscriptionRegistry::new();
|
||||||
|
let conn_a = Uuid::new_v4();
|
||||||
|
let conn_b = Uuid::new_v4();
|
||||||
|
let reaped = Uuid::new_v4();
|
||||||
|
let survivor = Uuid::new_v4();
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
conn_a,
|
||||||
|
"a".to_string(),
|
||||||
|
vec![Filter::new().kind(Kind::TextNote)],
|
||||||
|
Some(reaped),
|
||||||
|
);
|
||||||
|
registry.register(
|
||||||
|
conn_b,
|
||||||
|
"b".to_string(),
|
||||||
|
vec![Filter::new().kind(Kind::TextNote)],
|
||||||
|
Some(reaped),
|
||||||
|
);
|
||||||
|
registry.register(
|
||||||
|
conn_a,
|
||||||
|
"keep".to_string(),
|
||||||
|
vec![Filter::new().kind(Kind::TextNote)],
|
||||||
|
Some(survivor),
|
||||||
|
);
|
||||||
|
|
||||||
|
for conn_id in registry.channel_subscriber_conns(reaped) {
|
||||||
|
registry.remove_channel_subscriptions(conn_id, reaped);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(registry.channel_subscriber_conns(reaped).is_empty());
|
||||||
|
assert!(registry
|
||||||
|
.fan_out(&make_stored_event(Kind::TextNote, Some(reaped)))
|
||||||
|
.is_empty());
|
||||||
|
// Unrelated channel still fans out.
|
||||||
|
let survivor_matches = registry.fan_out(&make_stored_event(Kind::TextNote, Some(survivor)));
|
||||||
|
assert_eq!(survivor_matches.len(), 1);
|
||||||
|
assert_eq!(survivor_matches[0].1, "keep");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_global_kind_index_fan_out() {
|
fn test_global_kind_index_fan_out() {
|
||||||
// Global subscriptions with explicit kinds should use the global_kind_index
|
// Global subscriptions with explicit kinds should use the global_kind_index
|
||||||
|
|||||||
Reference in New Issue
Block a user