Files
buzz/crates/sprout-acp/src/relay.rs

3164 lines
124 KiB
Rust

//! Harness-side Sprout relay client.
//!
//! Connects to the Sprout relay via NIP-01 WebSocket, authenticates via NIP-42,
//! discovers channels via REST API, and streams events back to the harness main
//! loop. Also publishes ephemeral events (typing indicators) via the same
//! WebSocket connection.
//!
//! ## Architecture
//!
//! A background tokio task owns the WebSocket stream. It:
//! - Responds to Ping frames with Pong (preventing relay disconnect on long turns)
//! - Forwards `SproutEvent`s through an `mpsc` channel
//! - Handles reconnection with `since` filters to avoid event loss
//! - Responds to mid-session AUTH challenges
//! - Publishes ephemeral events (typing indicators) via `PublishEvent` commands
//!
//! `HarnessRelay` communicates with the background task via a `RelayCommand`
//! channel. `next_event()` reads from the event receiver.
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::Duration;
// ─── Named constants ──────────────────────────────────────────────────────────
/// Default capacity of the event channel from background task to harness.
/// Override with `SPROUT_ACP_EVENT_BUFFER` env var at startup.
const EVENT_CHANNEL_CAPACITY_DEFAULT: usize = 256;
/// Capacity of the command channel from harness to background task.
const CMD_CHANNEL_CAPACITY: usize = 64;
/// Read the event channel capacity from the environment, falling back to the
/// compiled-in default. Parsed once at call-site (connect time).
fn event_channel_capacity() -> usize {
std::env::var("SPROUT_ACP_EVENT_BUFFER")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.map(|v| v.max(1)) // mpsc::channel panics on capacity 0
.unwrap_or(EVENT_CHANNEL_CAPACITY_DEFAULT)
}
/// Maximum number of seen event IDs before the dedup set is rotated.
/// Two-generation dedup: each generation holds up to SEEN_ID_LIMIT/2 entries.
const SEEN_ID_LIMIT: usize = 12_000;
/// Interval between client-initiated WebSocket pings.
const PING_INTERVAL: Duration = Duration::from_secs(30);
/// If no pong is received within this duration after a ping, the connection is
/// considered dead and the background task triggers a reconnect.
const PONG_TIMEOUT: Duration = Duration::from_secs(10);
/// Timeout for individual ws.send() calls. Prevents a stalled socket from
/// wedging the background task indefinitely.
const WS_SEND_TIMEOUT_SECS: u64 = 10;
/// Diagnostic threshold: log when a connection has been stable for this long.
/// No backoff reset is implemented yet — this is a hook for future improvement.
const STABLE_CONNECTION_SECS: u64 = 60;
/// Seconds subtracted from `since` on resubscribe to tolerate clock skew.
const SINCE_SKEW_SECS: u64 = 5;
/// Timeout for the NIP-42 auth handshake steps.
const AUTH_TIMEOUT: Duration = Duration::from_secs(5);
/// Timeout for the TCP + WebSocket handshake in `do_connect`.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
use std::time::Instant;
use futures_util::{SinkExt, StreamExt};
use nostr::{Event, EventBuilder, Keys, Kind, Tag, Url as NostrUrl};
use serde_json::{json, Value};
use sprout_core::kind::{
KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_TYPING_INDICATOR,
};
use tokio::sync::mpsc;
use tokio::time::timeout;
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
use tracing::{debug, info, warn};
use uuid::Uuid;
use crate::config::ChannelFilter;
// ── Types ─────────────────────────────────────────────────────────────────────
/// Metadata about a channel, populated at discovery time.
#[derive(Debug, Clone)]
pub struct ChannelInfo {
pub name: String,
pub channel_type: String,
}
/// Lightweight REST client for pre-prompt context fetches.
///
/// Extracted from `HarnessRelay` fields so it can be shared (via `Arc`) with
/// spawned prompt tasks without giving them access to the WebSocket.
#[derive(Debug, Clone)]
pub struct RestClient {
pub http: reqwest::Client,
pub base_url: String,
pub api_token: Option<String>,
pub keys: Keys,
}
/// Whether an HTTP status code is retriable (transient server/rate-limit errors).
fn is_retriable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 429 | 502 | 503 | 504)
}
/// Base retry delays for transient HTTP failures: 500ms, 1s, 2s.
/// Jitter (±20%) is applied at call time via `jittered_duration`.
const REST_RETRY_BASE_DELAYS: [Duration; 3] = [
Duration::from_millis(500),
Duration::from_millis(1000),
Duration::from_millis(2000),
];
impl RestClient {
/// Retry helper: executes `build_request` up to 4 times (1 attempt + 3 retries)
/// on transient failures (429, 502, 503, 504, timeout, connect errors).
/// Retry delays are jittered to prevent thundering-herd.
///
/// Safety: all Sprout REST endpoints used by the harness are idempotent or
/// deduplicated server-side. GET/PUT/DELETE are inherently safe to retry.
/// POST /api/events publishes signed Nostr events whose IDs are deterministic
/// hashes — the relay deduplicates by event ID per NIP-01, so retries cannot
/// produce duplicate side effects.
async fn request_with_retry<F, Fut>(
&self,
method: &str,
path: &str,
build_request: F,
) -> Result<reqwest::Response, RelayError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
{
let mut last_err = None;
for (attempt, delay) in std::iter::once(None)
.chain(REST_RETRY_BASE_DELAYS.iter().map(|d| Some(*d)))
.enumerate()
{
if let Some(base) = delay {
let jittered = jittered_duration(base);
tracing::debug!(
"retrying {method} {path} (attempt {attempt}) in {:.1}s",
jittered.as_secs_f64()
);
tokio::time::sleep(jittered).await;
}
match build_request().await {
Ok(resp) if resp.status().is_success() => return Ok(resp),
Ok(resp) if is_retriable_status(resp.status()) => {
let status = resp.status();
tracing::warn!("{method} {path} returned retriable HTTP {status}");
last_err = Some(RelayError::Http(format!(
"{method} {path} returned HTTP {status}"
)));
}
Ok(resp) => {
// Non-retriable error (401, 403, 404, etc.) — fail immediately.
return Err(RelayError::Http(format!(
"{method} {} returned HTTP {}",
path,
resp.status()
)));
}
Err(e) if e.is_timeout() || e.is_connect() => {
tracing::warn!("{method} {path} network error: {e}");
last_err = Some(RelayError::Http(e.to_string()));
}
Err(e) => return Err(RelayError::Http(e.to_string())),
}
}
Err(last_err
.unwrap_or_else(|| RelayError::Http(format!("{method} {path} failed after retries"))))
}
/// GET a JSON endpoint with retry on transient failures (429, 502, 503, 504).
pub async fn get_json(&self, path: &str) -> Result<Value, RelayError> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.request_with_retry("GET", path, || {
let builder = apply_auth(self.http.get(&url), &self.api_token, &self.keys);
builder.send()
})
.await?;
resp.json()
.await
.map_err(|e| RelayError::Http(e.to_string()))
}
/// PUT a JSON body to an endpoint, returning the parsed response.
///
/// Returns `Value::Null` for empty response bodies (e.g. 204 No Content).
pub async fn put_json(&self, path: &str, body: &Value) -> Result<Value, RelayError> {
let url = format!("{}{}", self.base_url, path);
let body = body.clone();
let resp = self
.request_with_retry("PUT", path, || {
let builder =
apply_auth(self.http.put(&url).json(&body), &self.api_token, &self.keys);
builder.send()
})
.await?;
let text = resp
.text()
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
if text.is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(&text).map_err(|e| RelayError::Http(e.to_string()))
}
/// POST a JSON body to an endpoint, returning the parsed response.
///
/// Returns `Value::Null` for empty response bodies (e.g. 204 No Content).
pub async fn post_json(&self, path: &str, body: &Value) -> Result<Value, RelayError> {
let url = format!("{}{}", self.base_url, path);
let body = body.clone();
let resp = self
.request_with_retry("POST", path, || {
let builder = apply_auth(
self.http.post(&url).json(&body),
&self.api_token,
&self.keys,
);
builder.send()
})
.await?;
let text = resp
.text()
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
if text.is_empty() {
return Ok(Value::Null);
}
serde_json::from_str(&text).map_err(|e| RelayError::Http(e.to_string()))
}
/// DELETE an endpoint. Returns `Ok(())` on 2xx.
#[allow(dead_code)]
pub async fn delete(&self, path: &str) -> Result<(), RelayError> {
let url = format!("{}{}", self.base_url, path);
self.request_with_retry("DELETE", path, || {
let builder = apply_auth(self.http.delete(&url), &self.api_token, &self.keys);
builder.send()
})
.await?;
Ok(())
}
}
/// Events the harness cares about.
#[derive(Debug, Clone)]
pub struct SproutEvent {
/// Which channel this event belongs to.
pub channel_id: Uuid,
/// The underlying Nostr event.
pub event: Event,
}
/// Errors from relay operations.
#[derive(Debug, thiserror::Error)]
pub enum RelayError {
#[error("WebSocket error: {0}")]
WebSocket(Box<tokio_tungstenite::tungstenite::Error>),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Auth failed: {0}")]
AuthFailed(String),
#[error("No auth challenge received")]
NoAuthChallenge,
#[error("Connection closed")]
ConnectionClosed,
#[error("Timeout")]
Timeout,
#[error("HTTP error: {0}")]
Http(String),
#[error("Unexpected message: {0}")]
UnexpectedMessage(String),
}
impl From<nostr::event::builder::Error> for RelayError {
fn from(e: nostr::event::builder::Error) -> Self {
RelayError::AuthFailed(e.to_string())
}
}
// ── Internal relay message types ──────────────────────────────────────────────
/// A parsed NIP-01 relay message.
#[derive(Debug, Clone)]
enum RelayMessage {
Event {
subscription_id: String,
event: Box<Event>,
},
Ok {
event_id: String,
accepted: bool,
message: String,
},
Eose {
subscription_id: String,
},
Closed {
subscription_id: String,
message: String,
},
Notice {
message: String,
},
Auth {
challenge: String,
},
}
// ── Commands sent from HarnessRelay to the background task ───────────────────
/// Subscription ID for the global membership notification subscription.
const MEMBERSHIP_NOTIF_SUB_ID: &str = "membership-notif";
/// Commands sent from `HarnessRelay` to the background WebSocket task.
enum RelayCommand {
/// Subscribe to a channel (sends a NIP-01 REQ) with the given filter.
Subscribe {
channel_id: Uuid,
filter: ChannelFilter,
},
/// Unsubscribe from a channel (sends a NIP-01 CLOSE).
#[allow(dead_code)]
Unsubscribe { channel_id: Uuid },
/// Reconnect to the relay (re-authenticate and resubscribe).
Reconnect,
/// Shut down the background task.
Shutdown,
/// Subscribe to global membership notifications.
SubscribeMembership,
/// Publish a signed event to the relay (for typing indicators, etc.).
PublishEvent { event: Box<Event> },
/// Set the startup watermark timestamp for Finding #22.
/// The background task uses this as the floor `since` for membership
/// notification replay so events before startup are never re-delivered.
SetStartupWatermark { ts: u64 },
}
// ── WebSocket stream type alias ───────────────────────────────────────────────
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
// ── HarnessRelay ──────────────────────────────────────────────────────────────
/// Harness-side relay client.
///
/// Connects to the Sprout relay, authenticates via NIP-42, and streams
/// matching events for subscribed channels.
///
/// A background tokio task owns the WebSocket connection and responds to
/// Ping frames, preventing disconnection during long agent turns.
pub struct HarnessRelay {
/// Receiver for events forwarded by the background task.
event_rx: mpsc::Receiver<Option<SproutEvent>>,
/// Sender for commands to the background task.
cmd_tx: mpsc::Sender<RelayCommand>,
/// HTTP client for REST API calls.
http: reqwest::Client,
/// WebSocket URL of the relay.
relay_url: String,
/// Optional API token for Bearer auth.
api_token: Option<String>,
/// Keys used for NIP-42 signing.
keys: Keys,
/// Agent public key (hex) used as the `#p` filter on subscriptions.
#[allow(dead_code)]
agent_pubkey_hex: String,
/// Handle to the background task (for clean shutdown).
/// Wrapped in `Option` so `shutdown()` can take ownership without conflicting
/// with `Drop` (which only has `&mut self`).
bg_handle: Option<tokio::task::JoinHandle<()>>,
}
impl HarnessRelay {
// ── Public API ────────────────────────────────────────────────────────────
/// Connect to relay and authenticate via NIP-42.
pub async fn connect(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
agent_pubkey_hex: &str,
) -> Result<Self, RelayError> {
// Perform the initial connection and auth handshake.
// Finding #8: capture the handshake buffer and pass it to the background
// task so buffered messages aren't silently discarded.
let (ws, handshake_buffer) = do_connect(relay_url, keys, api_token).await?;
let (event_tx, event_rx) = mpsc::channel::<Option<SproutEvent>>(event_channel_capacity());
let (cmd_tx, cmd_rx) = mpsc::channel::<RelayCommand>(CMD_CHANNEL_CAPACITY);
let bg_keys = keys.clone();
let bg_relay_url = relay_url.to_string();
let bg_api_token = api_token.map(|t| t.to_string());
let bg_agent_pubkey_hex = agent_pubkey_hex.to_string();
let bg_handle = tokio::spawn(async move {
run_background_task(
ws,
handshake_buffer,
event_tx,
cmd_rx,
bg_keys,
bg_relay_url,
bg_api_token,
bg_agent_pubkey_hex,
)
.await;
});
Ok(Self {
event_rx,
cmd_tx,
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?,
relay_url: relay_url.to_string(),
api_token: api_token.map(|t| t.to_string()),
keys: keys.clone(),
agent_pubkey_hex: agent_pubkey_hex.to_string(),
bg_handle: Some(bg_handle),
})
}
/// Discover channels the agent is a member of via `GET /api/channels?member=true`.
///
/// Uses the retry-enabled `RestClient::get_json` so transient 502/503/429
/// errors during startup don't abort the harness.
pub async fn discover_channels(&self) -> Result<HashMap<Uuid, ChannelInfo>, RelayError> {
let rest = self.rest_client();
let body = rest.get_json("/api/channels?member=true").await?;
let channels = body
.as_array()
.ok_or_else(|| RelayError::Http("expected JSON array from /api/channels".into()))?;
let mut map = HashMap::with_capacity(channels.len());
for ch in channels {
if let Some(id_str) = ch.get("id").and_then(|v| v.as_str()) {
match id_str.parse::<Uuid>() {
Ok(uuid) => {
let name = ch
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let channel_type = ch
.get("channel_type")
.and_then(|v| v.as_str())
.unwrap_or("stream")
.to_string();
map.insert(uuid, ChannelInfo { name, channel_type });
}
Err(e) => {
warn!("skipping channel with unparseable id {id_str:?}: {e}");
}
}
}
}
debug!("discovered {} channel(s)", map.len());
Ok(map)
}
/// Build a [`RestClient`] that shares this relay's HTTP credentials.
///
/// The returned client is cheap to clone (wraps `reqwest::Client` which is
/// internally `Arc`-ed) and safe to share across spawned tasks via `Arc`.
pub fn rest_client(&self) -> RestClient {
RestClient {
http: self.http.clone(),
base_url: relay_ws_to_http(&self.relay_url),
api_token: self.api_token.clone(),
keys: self.keys.clone(),
}
}
/// Subscribe to events in a channel using the given filter.
///
/// Sends a `Subscribe` command to the background task, which issues the
/// NIP-01 `REQ` built from `filter`. Subscription ID is `ch-<uuid>`.
pub async fn subscribe_channel(
&mut self,
channel_id: Uuid,
filter: ChannelFilter,
) -> Result<(), RelayError> {
self.cmd_tx
.send(RelayCommand::Subscribe { channel_id, filter })
.await
.map_err(|_| RelayError::ConnectionClosed)?;
debug!("queued subscribe for channel {channel_id}");
Ok(())
}
/// Subscribe to membership notifications for this agent.
pub async fn subscribe_membership_notifications(&mut self) -> Result<(), RelayError> {
self.cmd_tx
.send(RelayCommand::SubscribeMembership)
.await
.map_err(|_| RelayError::ConnectionClosed)?;
Ok(())
}
/// Unsubscribe from a channel.
#[allow(dead_code)]
pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> {
self.cmd_tx
.send(RelayCommand::Unsubscribe { channel_id })
.await
.map_err(|_| RelayError::ConnectionClosed)?;
debug!("queued unsubscribe for channel {channel_id}");
Ok(())
}
/// Wait for the next event from any subscribed channel.
///
/// Reads from the background task's event channel. Returns `None` on
/// connection loss — the caller should call [`reconnect`](Self::reconnect).
pub async fn next_event(&mut self) -> Option<SproutEvent> {
// The background task sends `None` to signal connection loss.
self.event_rx.recv().await.flatten()
}
/// Publish a signed event to the relay via the background WebSocket task.
///
/// Blocks until the command channel has capacity. For ephemeral events
/// (typing indicators) prefer [`try_publish_event`] which never blocks.
#[allow(dead_code)] // Public API — callers outside the harness may use this
pub async fn publish_event(&self, event: Event) -> Result<(), RelayError> {
self.cmd_tx
.send(RelayCommand::PublishEvent {
event: Box::new(event),
})
.await
.map_err(|_| RelayError::ConnectionClosed)
}
/// Fire-and-forget publish — uses `try_send` so it never blocks the caller.
///
/// Suitable for ephemeral commands like typing indicators where dropping
/// the event on a full command channel is acceptable.
pub fn try_publish_event(&self, event: Event) -> Result<(), RelayError> {
self.cmd_tx
.try_send(RelayCommand::PublishEvent {
event: Box::new(event),
})
.map_err(|_| RelayError::ConnectionClosed)
}
/// Build a typing indicator event (kind:20002) for a channel.
pub fn build_typing_event(
&self,
channel_id: Uuid,
root_event_id: Option<&str>,
parent_event_id: Option<&str>,
) -> Result<Event, RelayError> {
let h_tag = Tag::parse(&["h", &channel_id.to_string()])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?;
let mut tags = vec![h_tag];
if let Some(parent) = parent_event_id {
if let Some(root) = root_event_id {
if root != parent {
tags.push(
Tag::parse(&["e", root, "", "root"])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
);
}
}
tags.push(
Tag::parse(&["e", parent, "", "reply"])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
);
}
let event = EventBuilder::new(Kind::Custom(KIND_TYPING_INDICATOR as u16), "", tags)
.sign_with_keys(&self.keys)?;
Ok(event)
}
/// Set the startup watermark timestamp (Finding #22).
///
/// Call this once after `connect()` with the Unix timestamp captured just
/// before the relay connection was established. The background task uses
/// this as the floor `since` for membership notification replay so events
/// predating this session are never re-delivered after reconnect.
pub async fn set_startup_watermark(&self, ts: u64) -> Result<(), RelayError> {
self.cmd_tx
.send(RelayCommand::SetStartupWatermark { ts })
.await
.map_err(|_| RelayError::ConnectionClosed)
}
/// Reconnect after connection loss. Instructs the background task to
/// re-authenticate and resubscribe to all previously active channels.
pub async fn reconnect(&mut self) -> Result<(), RelayError> {
warn!("relay connection lost — reconnecting…");
self.cmd_tx
.send(RelayCommand::Reconnect)
.await
.map_err(|_| RelayError::ConnectionClosed)?;
Ok(())
}
}
impl HarnessRelay {
/// Graceful async shutdown — sends Shutdown command and waits up to 5s for
/// the background task to finish. Use this from async contexts instead of
/// relying on `Drop` (which aborts immediately).
pub async fn shutdown(mut self) {
let _ = self.cmd_tx.send(RelayCommand::Shutdown).await;
if let Some(handle) = self.bg_handle.take() {
let abort_handle = handle.abort_handle();
if tokio::time::timeout(Duration::from_secs(5), handle)
.await
.is_err()
{
tracing::warn!("relay background task did not finish in 5s — aborting");
abort_handle.abort();
}
}
}
}
impl Drop for HarnessRelay {
fn drop(&mut self) {
// Best-effort shutdown signal; ignore errors (task may already be done).
let _ = self.cmd_tx.try_send(RelayCommand::Shutdown);
if let Some(handle) = self.bg_handle.take() {
handle.abort();
}
}
}
// ── Background task ───────────────────────────────────────────────────────────
/// Two-generation dedup set with bounded memory.
///
/// Mitigates the "amnesia window" caused by clearing the entire set at once.
/// When `current` reaches `limit/2` entries it is rotated into `previous`.
/// At any point we remember between `limit/2` and `limit` recent IDs.
/// The oldest `limit/2` IDs are forgotten on each rotation — this is the
/// inherent tradeoff of bounded-memory dedup. For the default limit of
/// 12,000, the worst case is that an ID seen 6,001+ inserts ago may be
/// replayed as new. This is acceptable for Nostr event dedup where the
/// `since` filter provides the primary replay protection.
struct TwoGenDedup {
current: HashSet<String>,
previous: HashSet<String>,
limit: usize,
}
impl TwoGenDedup {
fn new(limit: usize) -> Self {
Self {
current: HashSet::new(),
previous: HashSet::new(),
limit,
}
}
fn contains(&self, id: &str) -> bool {
self.current.contains(id) || self.previous.contains(id)
}
/// Insert `id`. Returns `true` if it was new (not a duplicate).
fn insert(&mut self, id: String) -> bool {
if self.contains(&id) {
return false;
}
self.current.insert(id);
if self.current.len() >= self.limit / 2 {
// Rotate: current → previous, start fresh current.
self.previous = std::mem::take(&mut self.current);
}
true
}
/// Remove an ID (used to un-deduplicate a dropped event so it can be
/// replayed after reconnect).
fn remove(&mut self, id: &str) {
self.current.remove(id);
self.previous.remove(id);
}
}
/// State maintained by the background WebSocket task.
struct BgState {
/// Active subscriptions: channel_id → subscription_id string.
active_subscriptions: HashMap<Uuid, String>,
/// Most recent `created_at` timestamp seen per channel (for `since` filter).
last_seen: HashMap<Uuid, u64>,
/// Two-generation dedup set of event IDs seen.
seen_ids: TwoGenDedup,
/// Per-channel filter used on subscribe (for resubscribe after reconnect).
active_filters: HashMap<Uuid, ChannelFilter>,
/// Oldest timestamp of a membership notification that was dropped due to
/// backpressure. If set, reconnect replay must start from this timestamp
/// (minus skew) to re-deliver the lost event. Reset on successful reconnect.
membership_dropped_since: Option<u64>,
/// Newest successfully-enqueued membership notification timestamp.
/// Used as the `since` for reconnect replay when no events were dropped.
membership_last_seen: Option<u64>,
/// Whether the membership notification subscription is active.
membership_sub_active: bool,
/// Oldest dropped channel-event timestamp per channel, keyed by channel_id.
/// Mirrors `membership_dropped_since` but for ordinary channel events.
/// On reconnect resubscribe, `since` = min(last_seen, channel_dropped_since).
/// Cleared per-channel after a successful resubscribe.
channel_dropped_since: HashMap<Uuid, u64>,
/// Set by the backpressure handler when the event channel is full.
/// The main loop checks this flag and triggers a proactive resubscribe
/// (without waiting for a disconnect) so dropped events are replayed.
proactive_resubscribe_needed: bool,
/// Unix timestamp captured just before the relay connection was established
/// (Finding #22). Used as the floor `since` for membership notification
/// replay so events predating this session are never re-delivered.
startup_watermark: Option<u64>,
/// Wall-clock timestamp when each channel was first subscribed.
/// Used as the `since` fallback on reconnect for channels that have no
/// `last_seen` or `channel_dropped_since`. This prevents channels joined
/// after startup from replaying from `startup_watermark` (which could be
/// hours old), while still allowing startup-era channels to use the
/// startup watermark via their `subscribe_since ≈ startup_watermark`.
subscribe_since: HashMap<Uuid, u64>,
}
impl BgState {
fn new() -> Self {
Self {
active_subscriptions: HashMap::new(),
last_seen: HashMap::new(),
seen_ids: TwoGenDedup::new(SEEN_ID_LIMIT),
active_filters: HashMap::new(),
membership_dropped_since: None,
membership_last_seen: None,
membership_sub_active: false,
channel_dropped_since: HashMap::new(),
proactive_resubscribe_needed: false,
startup_watermark: None,
subscribe_since: HashMap::new(),
}
}
/// Record a received event for dedup and `since` tracking.
/// Returns `true` if the event is new (not a duplicate).
fn record_event(&mut self, channel_id: Uuid, event: &Event) -> bool {
let id_hex = event.id.to_hex();
// Two-generation dedup: no amnesia window on rotation.
if !self.seen_ids.insert(id_hex) {
return false;
}
// Update last_seen timestamp.
let ts = event.created_at.as_u64();
self.last_seen
.entry(channel_id)
.and_modify(|t| *t = (*t).max(ts))
.or_insert(ts);
true
}
/// Compute the `since` timestamp for a channel (re)subscribe.
///
/// Picks the earliest of `last_seen` and `channel_dropped_since` so
/// the replay window covers both successfully processed events and any
/// that were dropped due to queue pressure. Falls back to the per-channel
/// `subscribe_since` (set at first subscribe) or `startup_watermark`.
fn channel_since(&self, channel_id: &Uuid) -> Option<u64> {
let last_seen = self.last_seen.get(channel_id).copied();
let dropped = self.channel_dropped_since.get(channel_id).copied();
match (last_seen, dropped) {
(Some(l), Some(d)) => Some(l.min(d)),
(Some(l), None) => Some(l),
(None, Some(d)) => Some(d),
(None, None) => self
.subscribe_since
.get(channel_id)
.copied()
.or(self.startup_watermark),
}
}
/// Clear all per-channel state for a channel that is being unsubscribed.
/// Prevents stale replay on re-subscribe and avoids unbounded state growth
/// for channels that are removed and never re-added.
fn clear_channel_state(&mut self, channel_id: &Uuid) {
self.last_seen.remove(channel_id);
self.subscribe_since.remove(channel_id);
self.channel_dropped_since.remove(channel_id);
self.active_filters.remove(channel_id);
}
}
/// Record a command's intent in state while disconnected (no WebSocket).
///
/// Subscribe/Unsubscribe/SubscribeMembership record intent so reconnect
/// restores the right subscriptions. SetStartupWatermark floors the replay
/// window. PublishEvent and Reconnect are no-ops while disconnected.
///
/// Callers MUST handle `Shutdown` before calling — reaching the Shutdown
/// arm here is a logic error.
fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) {
match cmd {
RelayCommand::Subscribe { channel_id, filter } => {
state
.active_subscriptions
.insert(channel_id, channel_sub_id(channel_id));
state.active_filters.insert(channel_id, filter);
state.subscribe_since.entry(channel_id).or_insert_with(|| {
// Use startup_watermark as floor when available — closes the
// blind spot between watermark capture and first REQ.
state.startup_watermark.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
});
}
RelayCommand::Unsubscribe { channel_id } => {
state.active_subscriptions.remove(&channel_id);
state.clear_channel_state(&channel_id);
}
RelayCommand::SubscribeMembership => {
state.membership_sub_active = true;
}
RelayCommand::SetStartupWatermark { ts } => {
state.startup_watermark = Some(ts);
if state.membership_last_seen.is_none() {
state.membership_last_seen = Some(ts);
}
}
// Ephemeral events are meaningless while disconnected.
RelayCommand::PublishEvent { .. } => {}
// Already reconnecting — redundant.
RelayCommand::Reconnect => {}
// Callers MUST handle Shutdown before calling this function.
RelayCommand::Shutdown => {
debug_assert!(
false,
"Shutdown must be handled by caller, not apply_command_to_state"
);
}
}
}
/// Execute a command on a live WebSocket connection.
///
/// Handles the five data commands: Subscribe, Unsubscribe,
/// SubscribeMembership, PublishEvent, SetStartupWatermark. Callers handle
/// Shutdown and Reconnect for control flow before dispatching here.
///
/// Returns `true` if the command succeeded (or was a no-op). Returns `false`
/// if a WebSocket send failed — the caller should treat this as a dead socket
/// and trigger reconnect. On failure, subscription intent is preserved in
/// state via [`apply_command_to_state`] so reconnect will restore it.
async fn execute_connected_command(
ws: &mut WsStream,
state: &mut BgState,
agent_pubkey_hex: &str,
cmd: RelayCommand,
) -> bool {
match cmd {
RelayCommand::Subscribe { channel_id, filter } => {
// Seed subscribe_since BEFORE computing since — on first
// subscribe, this provides the fallback timestamp that
// closes the startup blind spot. Use startup_watermark as
// floor when available so events between watermark capture
// and this REQ are not missed.
state.subscribe_since.entry(channel_id).or_insert_with(|| {
state.startup_watermark.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
});
let since = state
.last_seen
.get(&channel_id)
.copied()
.or_else(|| state.subscribe_since.get(&channel_id).copied());
let sent =
send_subscribe(ws, state, channel_id, agent_pubkey_hex, since, &filter).await;
if sent {
state
.active_subscriptions
.insert(channel_id, channel_sub_id(channel_id));
state.active_filters.insert(channel_id, filter);
true
} else {
// Send failed — record intent so reconnect restores it.
warn!("subscribe REQ failed for channel {channel_id} — recording intent for reconnect");
apply_command_to_state(state, RelayCommand::Subscribe { channel_id, filter });
false
}
}
RelayCommand::Unsubscribe { channel_id } => {
if let Some(sub_id) = state.active_subscriptions.remove(&channel_id) {
let msg = json!(["CLOSE", sub_id]);
if let Ok(text) = serde_json::to_string(&msg) {
// Best-effort CLOSE — don't fail the command if send fails,
// because the intent (unsubscribe) is already applied to state.
let _ =
ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await;
}
debug!("unsubscribed from channel {channel_id}");
}
state.clear_channel_state(&channel_id);
true
}
RelayCommand::SubscribeMembership => {
let since = state.membership_last_seen.or(state.startup_watermark);
let sent = send_membership_subscribe(ws, agent_pubkey_hex, since).await;
if sent {
state.membership_sub_active = true;
if state.membership_last_seen.is_none() {
state.membership_last_seen = since;
}
true
} else {
// Send failed — record intent so reconnect restores it.
warn!("membership subscribe REQ failed — recording intent for reconnect");
state.membership_sub_active = true;
false
}
}
RelayCommand::PublishEvent { event } => {
let msg = json!(["EVENT", event]);
if let Ok(text) = serde_json::to_string(&msg) {
if let Err(e) =
ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await
{
// Ephemeral events (typing indicators) are best-effort.
// Log the failure but don't trigger reconnect — the next
// ping or read will detect the dead socket.
warn!("failed to publish event: {e}");
}
}
true
}
RelayCommand::SetStartupWatermark { ts } => {
state.startup_watermark = Some(ts);
if state.membership_last_seen.is_none() {
state.membership_last_seen = Some(ts);
}
debug!("startup watermark set to {ts}");
true
}
// Control-flow commands — callers handle these before dispatching.
RelayCommand::Shutdown | RelayCommand::Reconnect => {
debug_assert!(
false,
"Shutdown/Reconnect must be handled by caller, not execute_connected_command"
);
true
}
}
}
/// The main background task loop.
///
/// Owns the WebSocket stream, responds to Pings, forwards events, and handles
/// reconnection.
#[allow(clippy::too_many_arguments)]
async fn run_background_task(
mut ws: WsStream,
initial_handshake_buffer: std::collections::VecDeque<RelayMessage>,
event_tx: mpsc::Sender<Option<SproutEvent>>,
mut cmd_rx: mpsc::Receiver<RelayCommand>,
keys: Keys,
relay_url: String,
api_token: Option<String>,
agent_pubkey_hex: String,
) {
let mut state = BgState::new();
// Finding #8: process any messages buffered during the initial auth handshake.
// If a buffered message signals connection drop, trigger reconnect immediately.
let handshake_ok = process_handshake_buffer(
&mut ws,
initial_handshake_buffer,
&event_tx,
&mut state,
&keys,
&relay_url,
api_token.as_deref(),
&agent_pubkey_hex,
)
.await;
if !handshake_ok {
warn!("handshake buffer contained a drop signal — attempting autonomous reconnect");
// Don't wait for a caller-driven Reconnect command — the caller was
// never notified (no sentinel sent). Go straight to reconnect loop.
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws,
&mut cmd_rx,
&mut state,
&keys,
&relay_url,
api_token.as_deref(),
&agent_pubkey_hex,
&event_tx,
)
.await
{
ReconnectOutcome::Ok => {
if matches!(
drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await,
ReconnectOutcome::Shutdown
) {
return;
}
}
ReconnectOutcome::Shutdown => return,
ReconnectOutcome::Failed => {
if matches!(
wait_for_reconnect(
&mut ws,
&mut cmd_rx,
&mut state,
&keys,
&relay_url,
api_token.as_deref(),
&agent_pubkey_hex,
&event_tx,
true,
)
.await,
ReconnectOutcome::Shutdown
) {
return;
}
}
}
// ping_sent, last_pong, connected_since are initialized below —
// no reset needed here since they haven't been declared yet.
}
// Finding #31: client-initiated ping to detect silent connection death.
let mut ping_interval = tokio::time::interval(PING_INTERVAL);
ping_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut last_pong = Instant::now();
let mut ping_sent = false;
// Finding #42: track connection stability for backoff reset.
let mut connected_since = Instant::now();
let mut stable_logged = false;
loop {
// Finding #3: check proactive resubscribe flag before blocking on select!
if state.proactive_resubscribe_needed {
state.proactive_resubscribe_needed = false;
info!("proactive resubscribe triggered by backpressure event loss");
if !resubscribe_after_reconnect(&mut ws, &mut state, &agent_pubkey_hex).await {
warn!("proactive resubscribe had failures — triggering reconnect");
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws,
&mut cmd_rx,
&mut state,
&keys,
&relay_url,
api_token.as_deref(),
&agent_pubkey_hex,
&event_tx,
)
.await
{
ReconnectOutcome::Ok => {
if matches!(
drain_post_reconnect(
&mut ws,
&mut cmd_rx,
&mut state,
&agent_pubkey_hex
)
.await,
ReconnectOutcome::Shutdown
) {
return;
}
}
ReconnectOutcome::Shutdown => return,
ReconnectOutcome::Failed => {
if matches!(
wait_for_reconnect(
&mut ws,
&mut cmd_rx,
&mut state,
&keys,
&relay_url,
api_token.as_deref(),
&agent_pubkey_hex,
&event_tx,
true,
)
.await,
ReconnectOutcome::Shutdown
) {
return;
}
}
}
ping_sent = false;
last_pong = Instant::now();
connected_since = Instant::now();
stable_logged = false;
}
}
tokio::select! {
// ── Incoming WebSocket message ────────────────────────────────────
raw = ws.next() => {
// Determine if the socket is lost.
let socket_lost = match raw {
Some(Ok(msg)) => {
// Finding #31: track pong replies directly, before dispatch.
if matches!(msg, Message::Pong(_)) {
last_pong = Instant::now();
ping_sent = false;
false // pong is healthy — not a socket loss
} else {
!handle_ws_message(
msg,
&mut ws,
&event_tx,
&mut state,
&keys,
&relay_url,
api_token.as_deref(),
&agent_pubkey_hex,
)
.await
}
}
Some(Err(e)) => {
warn!("WebSocket error in background task: {e}");
true
}
None => {
debug!("WebSocket stream ended");
true
}
};
if socket_lost {
// Signal the caller, then attempt autonomous reconnect.
// Use try_send to avoid blocking on backpressure — recovery
// must not stall when the event channel is full.
let _ = event_tx.try_send(None);
let outcome = try_autonomous_reconnect(
&mut ws,
&mut cmd_rx,
&mut state,
&keys,
&relay_url,
api_token.as_deref(),
&agent_pubkey_hex,
&event_tx,
)
.await;
match outcome {
ReconnectOutcome::Shutdown => return,
ReconnectOutcome::Ok => {
if matches!(
drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await,
ReconnectOutcome::Shutdown
) { return; }
// Reset ping state after reconnect.
ping_sent = false;
last_pong = Instant::now();
connected_since = Instant::now();
stable_logged = false;
}
ReconnectOutcome::Failed => {
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
ping_sent = false;
last_pong = Instant::now();
connected_since = Instant::now();
stable_logged = false;
}
} // end match outcome
}
}
// ── Command from HarnessRelay ─────────────────────────────────────
cmd = cmd_rx.recv() => {
match cmd {
Some(RelayCommand::Reconnect) => {
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
ping_sent = false;
last_pong = Instant::now();
connected_since = Instant::now();
stable_logged = false;
}
Some(RelayCommand::Shutdown) | None => {
debug!("background task shutting down — sending close frame");
let _ = ws_send_timeout(
&mut ws,
Message::Close(None),
WS_SEND_TIMEOUT_SECS,
)
.await;
return;
}
Some(cmd) => {
let ok = execute_connected_command(
&mut ws,
&mut state,
&agent_pubkey_hex,
cmd,
)
.await;
if !ok {
// Send failed — socket is likely dead. Trigger reconnect.
warn!("command send failed — triggering reconnect");
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx,
).await {
ReconnectOutcome::Shutdown => return,
ReconnectOutcome::Ok => {
if matches!(
drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await,
ReconnectOutcome::Shutdown
) { return; }
}
ReconnectOutcome::Failed => {
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
}
}
ping_sent = false;
last_pong = Instant::now();
connected_since = Instant::now();
stable_logged = false;
}
}
}
}
// ── Finding #31: client-initiated ping ────────────────────────────
_ = ping_interval.tick() => {
if ping_sent && last_pong.elapsed() > PONG_TIMEOUT {
// No pong received after our last ping — connection is dead.
warn!("no pong received within {:?} — connection dead, reconnecting", PONG_TIMEOUT);
// Use try_send to avoid blocking on backpressure during recovery.
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx,
).await {
ReconnectOutcome::Shutdown => return,
ReconnectOutcome::Ok => {
if matches!(
drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await,
ReconnectOutcome::Shutdown
) { return; }
}
ReconnectOutcome::Failed => {
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
}
}
ping_sent = false;
last_pong = Instant::now();
connected_since = Instant::now();
stable_logged = false;
} else if !ping_sent {
if let Err(e) = ws_send_timeout(&mut ws, Message::Ping(vec![].into()), WS_SEND_TIMEOUT_SECS).await {
warn!("failed to send ping: {e} — triggering reconnect");
// Use try_send to avoid blocking on backpressure during recovery.
let _ = event_tx.try_send(None);
match try_autonomous_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx,
).await {
ReconnectOutcome::Shutdown => return,
ReconnectOutcome::Ok => {
if matches!(
drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await,
ReconnectOutcome::Shutdown
) { return; }
}
ReconnectOutcome::Failed => {
if matches!(
wait_for_reconnect(
&mut ws, &mut cmd_rx, &mut state, &keys, &relay_url,
api_token.as_deref(), &agent_pubkey_hex, &event_tx, true,
).await,
ReconnectOutcome::Shutdown
) { return; }
}
}
ping_sent = false;
last_pong = Instant::now();
connected_since = Instant::now();
stable_logged = false;
} else {
ping_sent = true;
debug!("sent ping to relay");
}
}
}
}
// Finding #42: log when connection has been stable for STABLE_CONNECTION_SECS.
// Log once when the connection has been stable. Diagnostic only.
if !stable_logged && connected_since.elapsed() > Duration::from_secs(STABLE_CONNECTION_SECS)
{
stable_logged = true;
debug!("connection stable for >{}s", STABLE_CONNECTION_SECS);
}
}
}
/// Handle a single WebSocket message in the background task.
///
/// Returns `false` if the connection has been lost (Close frame or unrecoverable
/// error), `true` otherwise.
#[allow(clippy::too_many_arguments)]
async fn handle_ws_message(
msg: Message,
ws: &mut WsStream,
event_tx: &mpsc::Sender<Option<SproutEvent>>,
state: &mut BgState,
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
agent_pubkey_hex: &str,
) -> bool {
match msg {
Message::Text(text) => {
let relay_msg = match parse_relay_message(&text) {
Ok(m) => m,
Err(e) => {
warn!("failed to parse relay message: {e} — raw: {text}");
return true;
}
};
match relay_msg {
RelayMessage::Event {
subscription_id,
event,
} => {
if subscription_id == MEMBERSHIP_NOTIF_SUB_ID {
// Membership notification — extract channel UUID from h tag.
let channel_uuid = match extract_h_tag_uuid(&event) {
Some(uuid) => uuid,
None => {
warn!("membership notification missing h tag — dropping");
return true;
}
};
// Dedup membership notifications through TwoGenDedup.
// We use seen_ids directly instead of record_event()
// because record_event() also updates last_seen, which
// would contaminate per-channel replay watermarks with
// membership-event timestamps and cause channel event
// loss on reconnect.
let event_id_hex = event.id.to_hex();
if !state.seen_ids.insert(event_id_hex.clone()) {
debug!(
channel_id = %channel_uuid,
event_id = %event_id_hex,
"duplicate membership notification — skipping"
);
return true;
}
let ts = event.created_at.as_u64();
let sprout_event = SproutEvent {
channel_id: channel_uuid,
event: *event,
};
// Finding #3: warn at 80% capacity.
let cap = event_tx.max_capacity();
let used = cap - event_tx.capacity();
if used >= (cap * 4 / 5) {
warn!(
used,
capacity = cap,
"event channel at ≥80% capacity — backpressure imminent"
);
}
match event_tx.try_send(Some(sprout_event)) {
Ok(()) => {
state.membership_last_seen =
Some(state.membership_last_seen.unwrap_or(0).max(ts));
}
Err(mpsc::error::TrySendError::Full(_)) => {
// Remove from dedup so reconnect replay can
// re-deliver this event (it was never forwarded
// to the harness).
state.seen_ids.remove(&event_id_hex);
// Track the oldest dropped timestamp so reconnect
// replay starts early enough to re-deliver it.
state.membership_dropped_since =
Some(state.membership_dropped_since.map_or(ts, |d| d.min(ts)));
// Finding #3: proactively trigger resubscribe without
// waiting for a disconnect.
state.proactive_resubscribe_needed = true;
warn!(
channel_id = %channel_uuid,
ts,
"membership notification dropped (backpressure) — proactive resubscribe queued"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => return false,
}
} else if let Some(channel_id) = channel_id_from_sub_id(&subscription_id) {
let ts = event.created_at.as_u64();
let event_id_hex = event.id.to_hex();
if state.record_event(channel_id, &event) {
let sprout_event = SproutEvent {
channel_id,
event: *event,
};
// Finding #3: warn at 80% capacity.
let cap = event_tx.max_capacity();
let used = cap - event_tx.capacity();
if used >= (cap * 4 / 5) {
warn!(
used,
capacity = cap,
"event channel at ≥80% capacity — backpressure imminent"
);
}
match event_tx.try_send(Some(sprout_event)) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(_)) => {
// Remove from dedup set so the replayed event
// won't be rejected as a duplicate after reconnect.
state.seen_ids.remove(&event_id_hex);
// Track the oldest dropped timestamp so reconnect
// replay starts early enough to re-deliver it.
state
.channel_dropped_since
.entry(channel_id)
.and_modify(|d| *d = (*d).min(ts))
.or_insert(ts);
// Finding #3: proactively trigger resubscribe.
state.proactive_resubscribe_needed = true;
warn!(
channel_id = %channel_id,
ts,
"event channel full — dropping event for channel {channel_id} — proactive resubscribe queued"
);
}
Err(mpsc::error::TrySendError::Closed(_)) => {
// Receiver dropped — shut down.
return false;
}
}
} else {
debug!("dropping duplicate event for channel {channel_id}");
}
} else {
warn!("received EVENT for unknown subscription {subscription_id}");
}
}
RelayMessage::Eose { subscription_id } => {
debug!("EOSE for subscription {subscription_id}");
}
RelayMessage::Notice { message } => {
// Fix 4: NOTICE at warn level.
tracing::warn!("relay NOTICE: {message}");
}
RelayMessage::Closed {
subscription_id,
message,
} => {
// Finding #15: CLOSED needs cleanup and resubscribe, not just logging.
// Classify the error to decide how to respond.
let is_auth_error = message.starts_with("auth-required")
|| message.starts_with("restricted")
|| message.contains("auth");
warn!(
"subscription {subscription_id} closed by relay: {message}{}",
if is_auth_error {
" [auth error — reconnect required]"
} else {
""
}
);
if is_auth_error {
// Auth errors require a full reconnect (re-handshake).
return false;
}
// Attempt targeted resubscribe. State is NOT cleared before
// the attempt — if the send fails and triggers reconnect,
// resubscribe_after_reconnect() needs the subscription to
// still be in state so it can restore it.
if subscription_id == MEMBERSHIP_NOTIF_SUB_ID {
let since =
match (state.membership_dropped_since, state.membership_last_seen) {
(Some(d), Some(l)) => Some(d.min(l)),
(Some(d), None) => Some(d),
(None, Some(l)) => Some(l),
(None, None) => state.startup_watermark,
};
let sent = send_membership_subscribe(ws, agent_pubkey_hex, since).await;
if sent {
// Success — subscription is live again.
state.membership_dropped_since = None;
} else {
// Resubscribe failed — likely half-dead socket.
// Keep membership_sub_active = true so reconnect restores it.
warn!(
"membership resubscribe failed after CLOSED — triggering reconnect"
);
return false;
}
} else if let Some(channel_id) = channel_id_from_sub_id(&subscription_id) {
// Guard: only resubscribe if the channel is still active.
// A delayed CLOSED for an already-unsubscribed channel must
// NOT resurrect the subscription (especially with a default
// permissive filter, which would be a fail-open regression).
if !state.active_subscriptions.contains_key(&channel_id) {
debug!("ignoring CLOSED for already-unsubscribed channel {channel_id}");
} else {
let since = state.channel_since(&channel_id);
let filter = match state.active_filters.get(&channel_id).cloned() {
Some(f) => f,
None => {
// Fail closed: missing filter state means the subscription
// intent is inconsistent. Trigger reconnect rather than
// resubscribing with a permissive wildcard.
warn!("missing filter for channel {channel_id} after CLOSED — triggering reconnect (fail-closed)");
return false;
}
};
let sent = send_subscribe(
ws,
state,
channel_id,
agent_pubkey_hex,
since,
&filter,
)
.await;
if sent {
// Success — update subscription ID (relay may assign new one).
state
.active_subscriptions
.insert(channel_id, channel_sub_id(channel_id));
state.channel_dropped_since.remove(&channel_id);
} else {
// Resubscribe failed — likely half-dead socket.
// Keep channel in active_subscriptions so reconnect restores it.
warn!("channel {channel_id} resubscribe failed after CLOSED — triggering reconnect");
return false;
}
} // end: channel is still active
} else {
warn!("CLOSED for unknown subscription {subscription_id} — ignoring");
}
}
RelayMessage::Auth { challenge } => {
// Finding #18: AUTH send failure must trigger reconnect.
debug!("received mid-session AUTH challenge — re-authenticating");
if let Err(e) =
send_auth_response(ws, &challenge, relay_url, keys, api_token).await
{
warn!("failed to respond to mid-session AUTH challenge: {e} — triggering reconnect");
return false;
}
}
RelayMessage::Ok {
event_id,
accepted,
message,
} => {
if !accepted && message.starts_with("auth") {
// Finding #18: AUTH OK with accepted=false means auth was rejected.
warn!("mid-session AUTH rejected (event {event_id}): {message} — triggering reconnect");
return false;
}
debug!("OK for event {event_id}: accepted={accepted} message={message}");
}
}
true
}
Message::Ping(data) => {
if let Err(e) = ws_send_timeout(ws, Message::Pong(data), WS_SEND_TIMEOUT_SECS).await {
warn!("failed to send pong: {e}");
return false;
}
true
}
Message::Close(_) => {
debug!("relay sent Close frame");
false
}
// Binary, Pong, Frame — ignore
_ => true,
}
}
/// Process messages buffered during the NIP-42 auth handshake (Finding #8).
///
/// `do_connect` buffers any non-AUTH/non-OK messages it receives while waiting
/// for the challenge and OK. Those messages would otherwise be silently
/// discarded. We replay them through the normal handler here.
#[allow(clippy::too_many_arguments)]
/// Returns `false` if any buffered message signals the connection should be dropped.
async fn process_handshake_buffer(
ws: &mut WsStream,
buffer: std::collections::VecDeque<RelayMessage>,
event_tx: &mpsc::Sender<Option<SproutEvent>>,
state: &mut BgState,
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
agent_pubkey_hex: &str,
) -> bool {
if buffer.is_empty() {
return true;
}
debug!("processing {} buffered handshake message(s)", buffer.len());
for relay_msg in buffer {
// Re-encode to text so we can reuse handle_ws_message.
// This is slightly wasteful but keeps the handler as the single
// source of truth for message dispatch.
let text = match &relay_msg {
RelayMessage::Event {
subscription_id,
event,
} => serde_json::to_string(&json!(["EVENT", subscription_id, event])).ok(),
RelayMessage::Eose { subscription_id } => {
serde_json::to_string(&json!(["EOSE", subscription_id])).ok()
}
RelayMessage::Notice { message } => {
serde_json::to_string(&json!(["NOTICE", message])).ok()
}
RelayMessage::Closed {
subscription_id,
message,
} => serde_json::to_string(&json!(["CLOSED", subscription_id, message])).ok(),
RelayMessage::Ok {
event_id,
accepted,
message,
} => serde_json::to_string(&json!(["OK", event_id, accepted, message])).ok(),
// AUTH in the buffer is stale — skip it.
RelayMessage::Auth { .. } => None,
};
if let Some(text) = text {
let should_continue = handle_ws_message(
Message::Text(text.into()),
ws,
event_tx,
state,
keys,
relay_url,
api_token,
agent_pubkey_hex,
)
.await;
if !should_continue {
return false;
}
}
}
true
}
/// Resubscribe all active channels and membership notifications after a
/// successful reconnect. Computes `since = min(last_seen, channel_dropped_since)`
/// per channel, and only clears the drop tracker when the REQ is confirmed sent.
///
/// Returns `true` if ALL subscriptions were sent successfully. Returns `false`
/// if any send failed — the caller should treat this as a failed reconnect
/// and retry, because a "connected" socket with missing subscriptions causes
/// silent event loss.
async fn resubscribe_after_reconnect(
ws: &mut WsStream,
state: &mut BgState,
agent_pubkey_hex: &str,
) -> bool {
let mut all_ok = true;
let channels: Vec<Uuid> = state.active_subscriptions.keys().copied().collect();
if !channels.is_empty() {
info!(
"resubscribing to {} channel(s) after reconnect",
channels.len()
);
for channel_id in channels {
let since = state.channel_since(&channel_id);
let filter = match state.active_filters.get(&channel_id).cloned() {
Some(f) => f,
None => {
// Fail closed: missing filter state means the subscription
// intent is inconsistent. Skip rather than resubscribe with
// a permissive wildcard that would widen the subscription.
warn!("missing filter for channel {channel_id} — skipping resubscribe (fail-closed)");
all_ok = false;
continue;
}
};
let sent =
send_subscribe(ws, state, channel_id, agent_pubkey_hex, since, &filter).await;
if sent {
state.channel_dropped_since.remove(&channel_id);
} else {
warn!("failed to resubscribe channel {channel_id} after reconnect");
all_ok = false;
}
}
}
if state.membership_sub_active {
let replay_since = match (state.membership_dropped_since, state.membership_last_seen) {
(Some(d), Some(l)) => Some(d.min(l)),
(Some(d), None) => Some(d),
(None, Some(l)) => Some(l),
(None, None) => state.startup_watermark,
};
let sent = send_membership_subscribe(ws, agent_pubkey_hex, replay_since).await;
if sent {
state.membership_dropped_since = None;
} else {
warn!("failed to resubscribe membership after reconnect");
all_ok = false;
}
}
all_ok
}
/// Attempt autonomous reconnect on socket loss.
///
/// Finding #42: 5 attempts with 1s→2s→4s→8s→16s backoff (was 3 attempts).
/// Finding #27: ±20% jitter on each sleep.
/// Finding #8: process handshake buffer on success.
///
/// Outcome of an autonomous reconnect attempt.
enum ReconnectOutcome {
/// Reconnected and resubscribed successfully.
Ok,
/// All attempts exhausted — caller should fall back to wait_for_reconnect.
Failed,
/// A Shutdown command was received during backoff — caller must return immediately.
Shutdown,
}
/// Drain all pending commands after a successful reconnect.
///
/// Processes queued commands that arrived while reconnecting. Reconnect
/// commands are silently dropped (already reconnected). Shutdown causes an
/// immediate close-frame + return of `ReconnectOutcome::Shutdown`. All other
/// commands are executed on the live socket via [`execute_connected_command`].
/// If any send fails, remaining commands are recorded as intent via
/// [`apply_command_to_state`] and the drain continues (the caller's next
/// read/ping will detect the dead socket).
async fn drain_post_reconnect(
ws: &mut WsStream,
cmd_rx: &mut mpsc::Receiver<RelayCommand>,
state: &mut BgState,
agent_pubkey_hex: &str,
) -> ReconnectOutcome {
let mut send_failed = false;
while let Ok(cmd) = cmd_rx.try_recv() {
if send_failed {
match cmd {
RelayCommand::Shutdown => {
let _ = ws_send_timeout(ws, Message::Close(None), WS_SEND_TIMEOUT_SECS).await;
return ReconnectOutcome::Shutdown;
}
RelayCommand::Reconnect => {}
cmd => apply_command_to_state(state, cmd),
}
continue;
}
match cmd {
RelayCommand::Reconnect => {
debug!("drained stale Reconnect after reconnect");
}
RelayCommand::Shutdown => {
debug!("shutdown received during post-reconnect drain");
let _ = ws_send_timeout(ws, Message::Close(None), WS_SEND_TIMEOUT_SECS).await;
return ReconnectOutcome::Shutdown;
}
cmd => {
let ok = execute_connected_command(ws, state, agent_pubkey_hex, cmd).await;
if !ok {
warn!("send failed during post-reconnect drain — recording remaining commands as intent");
send_failed = true;
}
}
}
}
ReconnectOutcome::Ok
}
/// Attempt autonomous reconnect on socket loss.
///
/// Returns [`ReconnectOutcome::Ok`] on success, [`ReconnectOutcome::Failed`]
/// if all attempts are exhausted, or [`ReconnectOutcome::Shutdown`] if a
/// Shutdown command was received during backoff sleep. Callers MUST check
/// for `Shutdown` and return immediately — do NOT fall through to
/// `wait_for_reconnect`, which would loop forever since the Shutdown command
/// was already consumed.
#[allow(clippy::too_many_arguments)]
async fn try_autonomous_reconnect(
ws: &mut WsStream,
cmd_rx: &mut mpsc::Receiver<RelayCommand>,
state: &mut BgState,
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
agent_pubkey_hex: &str,
event_tx: &mpsc::Sender<Option<SproutEvent>>,
) -> ReconnectOutcome {
// Finding #42: 5 attempts, up to 16s base backoff.
let backoffs = [
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(16),
];
for (attempt, delay) in backoffs.iter().enumerate() {
info!(
"autonomous reconnect attempt {}/{} to {relay_url}…",
attempt + 1,
backoffs.len()
);
match do_connect(relay_url, keys, api_token).await {
Ok((new_ws, handshake_buffer)) => {
*ws = new_ws;
info!("autonomous reconnect succeeded (attempt {})", attempt + 1);
// Finding #8: process buffered messages from the handshake.
let handshake_ok = process_handshake_buffer(
ws,
handshake_buffer,
event_tx,
state,
keys,
relay_url,
api_token,
agent_pubkey_hex,
)
.await;
if !handshake_ok {
warn!(
"handshake buffer drop signal after autonomous reconnect (attempt {})",
attempt + 1
);
// Fall through to backoff sleep instead of returning immediately.
// Returning false here would skip remaining attempts; continuing
// without sleep would drive a tight reconnect storm.
} else if resubscribe_after_reconnect(ws, state, agent_pubkey_hex).await {
return ReconnectOutcome::Ok;
} else {
warn!("resubscribe failed after autonomous reconnect — treating as failed attempt");
// Fall through to backoff sleep and retry.
}
}
Err(e) => {
warn!("autonomous reconnect attempt {} failed: {e}", attempt + 1);
}
}
// Backoff sleep between attempts (shared by handshake-drop and connect-error).
// Skip sleep on the final attempt — we'll fall through to the caller.
// Use select! so Shutdown commands are honoured during sleep.
if attempt + 1 < backoffs.len() {
let jittered = jittered_duration(*delay);
tracing::info!(
"retrying autonomous reconnect in {:.1}s",
jittered.as_secs_f64()
);
// Deadline-based sleep: commands processed during the wait don't
// reset the timer (prevents PublishEvent traffic from collapsing backoff).
let deadline = tokio::time::Instant::now() + jittered;
let sleep = tokio::time::sleep_until(deadline);
tokio::pin!(sleep);
loop {
tokio::select! {
_ = &mut sleep => break,
cmd = cmd_rx.recv() => {
match cmd {
Some(RelayCommand::Shutdown) | None => return ReconnectOutcome::Shutdown,
Some(cmd) => apply_command_to_state(state, cmd),
}
}
}
}
}
}
ReconnectOutcome::Failed
}
/// Attempt reconnection with exponential backoff. Resubscribes all active
/// channels with `since` filters on success.
///
/// If `skip_drain` is `false`, drains the command channel until a `Reconnect`
/// command arrives (used when called from the WS-error path where the caller
/// hasn't sent Reconnect yet). If `true`, skips the drain and reconnects
/// immediately (used when called from the `RelayCommand::Reconnect` arm where
/// the command was already consumed).
#[allow(clippy::too_many_arguments)]
async fn wait_for_reconnect(
ws: &mut WsStream,
cmd_rx: &mut mpsc::Receiver<RelayCommand>,
state: &mut BgState,
keys: &Keys,
relay_url: &str,
api_token: Option<&str>,
agent_pubkey_hex: &str,
event_tx: &mpsc::Sender<Option<SproutEvent>>,
skip_drain: bool,
) -> ReconnectOutcome {
if !skip_drain {
// Drain commands until we get Reconnect (or Shutdown).
// Other commands update state so reconnect reflects latest intent.
loop {
match cmd_rx.recv().await {
Some(RelayCommand::Reconnect) => break,
Some(RelayCommand::Shutdown) | None => return ReconnectOutcome::Shutdown,
Some(cmd) => apply_command_to_state(state, cmd),
}
}
}
// Finding #42: 6 attempts with backoff up to 32s + jitter (Finding #27).
// Finding #27: use tokio::select! so shutdown is honoured during sleep.
let backoffs = [
Duration::from_secs(1),
Duration::from_secs(2),
Duration::from_secs(4),
Duration::from_secs(8),
Duration::from_secs(16),
Duration::from_secs(32),
];
let mut attempt = 0usize;
let mut delay = Duration::from_secs(1);
loop {
info!("attempting relay reconnect to {relay_url}…");
match do_connect(relay_url, keys, api_token).await {
Ok((new_ws, handshake_buffer)) => {
*ws = new_ws;
info!("relay reconnected to {relay_url}");
// Finding #8: process buffered messages from the handshake.
let handshake_ok = process_handshake_buffer(
ws,
handshake_buffer,
event_tx,
state,
keys,
relay_url,
api_token,
agent_pubkey_hex,
)
.await;
if !handshake_ok {
warn!("handshake buffer contained a drop signal after reconnect — will retry with backoff");
// Fall through to the backoff sleep below instead of
// tight-looping. A relay that consistently fails the
// handshake would otherwise drive a reconnect storm.
} else if resubscribe_after_reconnect(ws, state, agent_pubkey_hex).await {
// Drain any commands that arrived during the final
// do_connect() + resubscribe (which don't poll cmd_rx).
return drain_post_reconnect(ws, cmd_rx, state, agent_pubkey_hex).await;
} else {
warn!("resubscribe failed after reconnect — will retry with backoff");
// Fall through to backoff sleep.
}
}
Err(e) => {
warn!("relay reconnect failed: {e}");
}
}
// Backoff sleep — shared by both handshake-drop and connect-error paths.
// Uses a deadline so commands processed during the wait don't reset
// the timer. Without this, periodic PublishEvent traffic (typing
// refresh every 3s) would collapse the jittered backoff into a
// reconnect storm.
let jittered = jittered_duration(delay);
warn!("retrying reconnect in {:.1}s", jittered.as_secs_f64());
let deadline = tokio::time::Instant::now() + jittered;
let sleep = tokio::time::sleep_until(deadline);
tokio::pin!(sleep);
loop {
tokio::select! {
_ = &mut sleep => break,
cmd = cmd_rx.recv() => {
match cmd {
Some(RelayCommand::Shutdown) | None => return ReconnectOutcome::Shutdown,
Some(cmd) => apply_command_to_state(state, cmd),
}
}
}
}
attempt += 1;
delay = if attempt < backoffs.len() {
backoffs[attempt]
} else {
Duration::from_secs(60)
};
}
}
/// Send a NIP-01 REQ for a channel, built from a [`ChannelFilter`].
///
/// - `kinds` is included only when `filter.kinds` is `Some`; `None` = wildcard.
/// - `#p` is included only when `filter.require_mention` is `true`.
/// - `#h` is always included (channel-scoped subscription).
/// - On first subscribe (`since` is `None`) adds `since=now` to avoid replaying
/// history. On reconnect (`since` is `Some`) subtracts [`SINCE_SKEW_SECS`].
///
/// Returns `true` if the REQ was successfully written to the WebSocket.
async fn send_subscribe(
ws: &mut WsStream,
_state: &BgState,
channel_id: Uuid,
agent_pubkey_hex: &str,
since: Option<u64>,
filter: &ChannelFilter,
) -> bool {
let sub_id = channel_sub_id(channel_id);
let mut req_filter = serde_json::Map::new();
// kinds — omit entirely for wildcard subscriptions.
if let Some(ref kinds) = filter.kinds {
req_filter.insert("kinds".into(), json!(kinds));
}
// #h — always present (channel scope).
req_filter.insert("#h".into(), json!([channel_id.to_string()]));
// #p — only when require_mention is true.
if filter.require_mention {
req_filter.insert("#p".into(), json!([agent_pubkey_hex]));
}
// since — on first subscribe use current time to skip history; on reconnect
// subtract skew buffer to catch events missed during the disconnect window.
let since_ts = match since {
Some(ts) => ts.saturating_sub(SINCE_SKEW_SECS),
None => std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
req_filter.insert("since".into(), json!(since_ts));
let req = json!(["REQ", sub_id, Value::Object(req_filter)]);
match serde_json::to_string(&req) {
Ok(text) => {
match ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await {
Ok(()) => {
debug!(
"subscribed to channel {channel_id}{}",
if since.is_some() {
" (with since filter)"
} else {
" (since=now)"
}
);
true
}
Err(e) => {
warn!("failed to send REQ for channel {channel_id}: {e}");
false
}
}
}
Err(e) => {
warn!("failed to serialize REQ for channel {channel_id}: {e}");
false
}
}
}
/// Send a NIP-01 REQ for membership notifications (kind:44100+44101, global, #p=[agent_pubkey]).
/// Returns `true` if the REQ was successfully written to the WebSocket.
async fn send_membership_subscribe(
ws: &mut WsStream,
agent_pubkey_hex: &str,
since: Option<u64>,
) -> bool {
let mut req_filter = serde_json::Map::new();
req_filter.insert(
"kinds".into(),
json!([
KIND_MEMBER_ADDED_NOTIFICATION,
KIND_MEMBER_REMOVED_NOTIFICATION
]),
);
req_filter.insert("#p".into(), json!([agent_pubkey_hex]));
let since_ts = match since {
Some(ts) => ts.saturating_sub(SINCE_SKEW_SECS),
None => std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
req_filter.insert("since".into(), json!(since_ts));
let req = json!(["REQ", MEMBERSHIP_NOTIF_SUB_ID, Value::Object(req_filter)]);
match serde_json::to_string(&req) {
Ok(text) => {
match ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await {
Ok(()) => {
debug!("subscribed to membership notifications (since={since_ts})");
true
}
Err(e) => {
warn!("failed to send membership notification REQ: {e}");
false
}
}
}
Err(e) => {
warn!("failed to serialize membership notification REQ: {e}");
false
}
}
}
/// Send a WebSocket message with a hard timeout.
///
/// All `ws.send()` calls go through here so a stalled TCP socket can't wedge
/// the background task. On timeout the caller should break out of the loop to
/// trigger reconnect.
async fn ws_send_timeout(
ws: &mut WsStream,
msg: Message,
timeout_secs: u64,
) -> Result<(), RelayError> {
tokio::time::timeout(Duration::from_secs(timeout_secs), ws.send(msg))
.await
.map_err(|_| RelayError::Timeout)?
.map_err(|e| RelayError::WebSocket(Box::new(e)))
}
/// Add ±20% jitter to a backoff duration using the nanosecond sub-second
/// component of the system clock as a cheap entropy source (no `rand` dep).
fn jittered_duration(base: Duration) -> Duration {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
// factor ∈ [0.8, 1.2)
let factor = 0.8 + (nanos as f64 / u32::MAX as f64) * 0.4;
base.mul_f64(factor)
}
/// Extract a channel UUID from the h tag of a Nostr event.
fn extract_h_tag_uuid(event: &nostr::Event) -> Option<Uuid> {
event.tags.iter().find_map(|tag| {
let tag_vec = tag.as_slice();
if tag_vec.len() >= 2 && tag_vec[0] == "h" {
tag_vec[1].parse::<Uuid>().ok()
} else {
None
}
})
}
/// Build and send a NIP-42 AUTH response event.
async fn send_auth_response(
ws: &mut WsStream,
challenge: &str,
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
) -> Result<(), RelayError> {
let relay_nostr_url: NostrUrl = relay_url
.parse()
.map_err(|e: url::ParseError| RelayError::Http(format!("invalid relay URL: {e}")))?;
let auth_event = if let Some(token) = api_token {
let tags = vec![
Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?,
Tag::parse(&["challenge", challenge])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
Tag::parse(&["auth_token", token])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?,
];
EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?
} else {
EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?
};
let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?;
ws_send_timeout(ws, Message::Text(auth_msg.into()), WS_SEND_TIMEOUT_SECS).await?;
debug!("sent AUTH response for challenge");
Ok(())
}
// ── Free functions ────────────────────────────────────────────────────────────
/// Convert a WebSocket URL to its HTTP equivalent.
///
/// `ws://host:port` → `http://host:port`
/// `wss://host:port` → `https://host:port`
/// Trailing slashes are stripped.
pub(crate) fn relay_ws_to_http(url: &str) -> String {
url.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
/// Build the subscription ID for a channel: `ch-<uuid>`.
pub(crate) fn channel_sub_id(channel_id: Uuid) -> String {
format!("ch-{channel_id}")
}
/// Extract a channel UUID from a subscription ID of the form `ch-<uuid>`.
/// Returns `None` if the format doesn't match or the UUID is invalid.
fn channel_id_from_sub_id(sub_id: &str) -> Option<Uuid> {
sub_id
.strip_prefix("ch-")
.and_then(|s| s.parse::<Uuid>().ok())
}
/// Apply the appropriate auth header to a reqwest request builder.
fn apply_auth(
builder: reqwest::RequestBuilder,
api_token: &Option<String>,
keys: &Keys,
) -> reqwest::RequestBuilder {
if let Some(ref token) = api_token {
builder.header("Authorization", format!("Bearer {token}"))
} else {
builder.header("X-Pubkey", keys.public_key().to_hex())
}
}
/// Parse a raw relay text frame into a typed [`RelayMessage`].
#[allow(private_interfaces)]
pub(crate) fn parse_relay_message(text: &str) -> Result<RelayMessage, RelayError> {
let arr: Vec<Value> = serde_json::from_str(text)?;
let msg_type = arr
.first()
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?;
match msg_type {
"EVENT" => {
let sub_id = arr
.get(1)
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
.to_string();
let event: Event = serde_json::from_value(
arr.get(2)
.cloned()
.ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?,
)?;
Ok(RelayMessage::Event {
subscription_id: sub_id,
event: Box::new(event),
})
}
"OK" => {
let event_id = arr
.get(1)
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
.to_string();
let accepted = arr.get(2).and_then(|v| v.as_bool()).unwrap_or(false);
let message = arr
.get(3)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(RelayMessage::Ok {
event_id,
accepted,
message,
})
}
"EOSE" => {
let sub_id = arr
.get(1)
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
.to_string();
Ok(RelayMessage::Eose {
subscription_id: sub_id,
})
}
"CLOSED" => {
let sub_id = arr
.get(1)
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
.to_string();
let message = arr
.get(2)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(RelayMessage::Closed {
subscription_id: sub_id,
message,
})
}
"NOTICE" => {
let message = arr
.get(1)
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Ok(RelayMessage::Notice { message })
}
"AUTH" => {
let challenge = arr
.get(1)
.and_then(|v| v.as_str())
.ok_or_else(|| RelayError::UnexpectedMessage(text.to_string()))?
.to_string();
Ok(RelayMessage::Auth { challenge })
}
other => Err(RelayError::UnexpectedMessage(format!(
"unknown message type: {other}"
))),
}
}
// ── Connection helpers ────────────────────────────────────────────────────────
/// Perform a single WebSocket connect + NIP-42 auth handshake.
///
/// Returns `(ws, buffer)` on success.
async fn do_connect(
relay_url: &str,
keys: &Keys,
api_token: Option<&str>,
) -> Result<(WsStream, VecDeque<RelayMessage>), RelayError> {
let parsed = relay_url
.parse::<url::Url>()
.map_err(|e| RelayError::Http(format!("invalid relay URL: {e}")))?;
let (ws, _response) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(parsed.as_str()))
.await
.map_err(|_| RelayError::ConnectionClosed)? // timeout → treat as connection failure
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
debug!("connected to relay at {relay_url}");
let mut ws = ws;
let mut buffer: VecDeque<RelayMessage> = VecDeque::new();
// ── Step 1: Wait for AUTH challenge ───────────────────────────────────
let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
// ── Step 2: Build and send kind:22242 auth event ──────────────────────
send_auth_response(&mut ws, &challenge, relay_url, keys, api_token).await?;
// ── Step 3: Wait for OK ───────────────────────────────────────────────
let event_id = {
// We need the event_id that was just sent. Re-derive it by signing again
// just to get the ID — but that's wasteful. Instead, parse the last sent
// message. Simpler: wait_for_ok accepts any OK (we just sent one event).
// The event_id in the OK will match whatever we sent.
// We'll accept the first OK we receive.
let ok = wait_for_any_ok(&mut ws, &mut buffer, AUTH_TIMEOUT).await?;
if !ok.accepted {
return Err(RelayError::AuthFailed(ok.message));
}
ok.event_id
};
debug!("NIP-42 authentication successful (event {event_id})");
Ok((ws, buffer))
}
/// Wait for an `AUTH` challenge from the relay, buffering any other messages.
async fn wait_for_auth_challenge(
ws: &mut WsStream,
buffer: &mut VecDeque<RelayMessage>,
timeout_dur: Duration,
) -> Result<String, RelayError> {
// Check if there's already one buffered.
if let Some(idx) = buffer
.iter()
.position(|m| matches!(m, RelayMessage::Auth { .. }))
{
if let Some(RelayMessage::Auth { challenge }) = buffer.remove(idx) {
return Ok(challenge);
}
}
let deadline = tokio::time::Instant::now() + timeout_dur;
loop {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.unwrap_or(Duration::ZERO);
if remaining.is_zero() {
return Err(RelayError::NoAuthChallenge);
}
let raw = timeout(remaining, ws.next())
.await
.map_err(|_| RelayError::NoAuthChallenge)?
.ok_or(RelayError::ConnectionClosed)?
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
match raw {
Message::Text(text) => {
let msg = parse_relay_message(&text)?;
match msg {
RelayMessage::Auth { challenge } => return Ok(challenge),
other => buffer.push_back(other),
}
}
Message::Ping(data) => {
ws_send_timeout(ws, Message::Pong(data), WS_SEND_TIMEOUT_SECS)
.await
.map_err(|_| RelayError::Timeout)?;
}
Message::Close(_) => return Err(RelayError::ConnectionClosed),
_ => {}
}
}
}
/// Response from an `OK` relay message.
struct OkResponse {
event_id: String,
accepted: bool,
message: String,
}
/// Wait for the first `OK` message from the relay (used after sending AUTH).
async fn wait_for_any_ok(
ws: &mut WsStream,
buffer: &mut VecDeque<RelayMessage>,
timeout_dur: Duration,
) -> Result<OkResponse, RelayError> {
// Check if there's already one buffered.
if let Some(idx) = buffer
.iter()
.position(|m| matches!(m, RelayMessage::Ok { .. }))
{
if let Some(RelayMessage::Ok {
event_id,
accepted,
message,
}) = buffer.remove(idx)
{
return Ok(OkResponse {
event_id,
accepted,
message,
});
}
}
let deadline = tokio::time::Instant::now() + timeout_dur;
loop {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.unwrap_or(Duration::ZERO);
if remaining.is_zero() {
return Err(RelayError::Timeout);
}
let raw = timeout(remaining, ws.next())
.await
.map_err(|_| RelayError::Timeout)?
.ok_or(RelayError::ConnectionClosed)?
.map_err(|e| RelayError::WebSocket(Box::new(e)))?;
match raw {
Message::Text(text) => {
let msg = parse_relay_message(&text)?;
match msg {
RelayMessage::Ok {
event_id,
accepted,
message,
} => {
return Ok(OkResponse {
event_id,
accepted,
message,
});
}
other => buffer.push_back(other),
}
}
Message::Ping(data) => {
ws_send_timeout(ws, Message::Pong(data), WS_SEND_TIMEOUT_SECS)
.await
.map_err(|_| RelayError::Timeout)?;
}
Message::Close(_) => return Err(RelayError::ConnectionClosed),
_ => {}
}
}
}
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
// ── relay_ws_to_http ──────────────────────────────────────────────────────
#[test]
fn relay_ws_to_http_plain() {
assert_eq!(
relay_ws_to_http("ws://localhost:3000"),
"http://localhost:3000"
);
}
#[test]
fn relay_ws_to_http_secure() {
assert_eq!(
relay_ws_to_http("wss://relay.example.com"),
"https://relay.example.com"
);
}
#[test]
fn relay_ws_to_http_strips_trailing_slash() {
assert_eq!(
relay_ws_to_http("ws://localhost:3000/"),
"http://localhost:3000"
);
}
#[test]
fn relay_ws_to_http_with_path() {
assert_eq!(
relay_ws_to_http("wss://relay.example.com/nostr"),
"https://relay.example.com/nostr"
);
}
#[test]
fn relay_ws_to_http_with_port_and_path() {
assert_eq!(
relay_ws_to_http("wss://relay.example.com:4000/ws"),
"https://relay.example.com:4000/ws"
);
}
// ── channel_sub_id ────────────────────────────────────────────────────────
#[test]
fn channel_sub_id_format() {
let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
assert_eq!(
channel_sub_id(uuid),
"ch-550e8400-e29b-41d4-a716-446655440000"
);
}
#[test]
fn channel_id_from_sub_id_roundtrip() {
let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
let sub_id = channel_sub_id(uuid);
let recovered = channel_id_from_sub_id(&sub_id).unwrap();
assert_eq!(recovered, uuid);
}
#[test]
fn channel_id_from_sub_id_invalid_prefix() {
assert!(channel_id_from_sub_id("sub-550e8400-e29b-41d4-a716-446655440000").is_none());
}
#[test]
fn channel_id_from_sub_id_invalid_uuid() {
assert!(channel_id_from_sub_id("ch-not-a-uuid").is_none());
}
#[test]
fn channel_id_from_sub_id_empty() {
assert!(channel_id_from_sub_id("").is_none());
}
// ── parse_relay_message ───────────────────────────────────────────────────
#[test]
fn parse_ok_accepted() {
let text = r#"["OK","abc123",true,""]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Ok {
event_id,
accepted,
message,
} => {
assert_eq!(event_id, "abc123");
assert!(accepted);
assert_eq!(message, "");
}
_ => panic!("expected Ok"),
}
}
#[test]
fn parse_ok_rejected() {
let text = r#"["OK","abc123",false,"blocked: spam"]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Ok {
event_id,
accepted,
message,
} => {
assert_eq!(event_id, "abc123");
assert!(!accepted);
assert_eq!(message, "blocked: spam");
}
_ => panic!("expected Ok"),
}
}
#[test]
fn parse_eose() {
let text = r#"["EOSE","sub-1"]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Eose { subscription_id } => {
assert_eq!(subscription_id, "sub-1");
}
_ => panic!("expected Eose"),
}
}
#[test]
fn parse_notice() {
let text = r#"["NOTICE","hello from relay"]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Notice { message } => {
assert_eq!(message, "hello from relay");
}
_ => panic!("expected Notice"),
}
}
#[test]
fn parse_notice_empty() {
let text = r#"["NOTICE"]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Notice { message } => {
assert_eq!(message, "");
}
_ => panic!("expected Notice"),
}
}
#[test]
fn parse_auth() {
let text = r#"["AUTH","some-challenge-string"]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Auth { challenge } => {
assert_eq!(challenge, "some-challenge-string");
}
_ => panic!("expected Auth"),
}
}
#[test]
fn parse_closed() {
let text = r#"["CLOSED","sub-2","error: rate-limited"]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(subscription_id, "sub-2");
assert_eq!(message, "error: rate-limited");
}
_ => panic!("expected Closed"),
}
}
#[test]
fn parse_closed_no_message() {
let text = r#"["CLOSED","sub-3"]"#;
let msg = parse_relay_message(text).unwrap();
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(subscription_id, "sub-3");
assert_eq!(message, "");
}
_ => panic!("expected Closed"),
}
}
#[test]
fn parse_unknown_type_returns_error() {
let text = r#"["UNKNOWN","data"]"#;
let result = parse_relay_message(text);
assert!(result.is_err());
match result.unwrap_err() {
RelayError::UnexpectedMessage(msg) => {
assert!(msg.contains("unknown message type"));
}
e => panic!("expected UnexpectedMessage, got {e:?}"),
}
}
#[test]
fn parse_invalid_json_returns_error() {
let text = "not json at all";
let result = parse_relay_message(text);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), RelayError::Json(_)));
}
#[test]
fn parse_empty_array_returns_error() {
let text = "[]";
let result = parse_relay_message(text);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
RelayError::UnexpectedMessage(_)
));
}
#[test]
fn parse_auth_missing_challenge_returns_error() {
let text = r#"["AUTH"]"#;
let result = parse_relay_message(text);
assert!(result.is_err());
}
#[test]
fn parse_eose_missing_sub_id_returns_error() {
let text = r#"["EOSE"]"#;
let result = parse_relay_message(text);
assert!(result.is_err());
}
// ── channel_sub_id subscription format ───────────────────────────────────
#[test]
fn subscription_id_starts_with_ch_prefix() {
let uuid = Uuid::new_v4();
let sub_id = channel_sub_id(uuid);
assert!(sub_id.starts_with("ch-"));
}
#[test]
fn subscription_id_contains_full_uuid() {
let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
let sub_id = channel_sub_id(uuid);
assert_eq!(sub_id, "ch-12345678-1234-5678-1234-567812345678");
}
// ── BgState: seen_ids deduplication ──────────────────────────────────────
/// Build a real signed Nostr event for testing BgState.
///
/// Uses `custom_created_at` so tests can control the timestamp.
/// The event ID is determined by the nostr signing process — we don't
/// control it, but we return it so callers can use it for dedup tests.
fn make_test_event(keys: &nostr::Keys, created_at_secs: u64) -> Event {
let ts = nostr::Timestamp::from(created_at_secs);
EventBuilder::new(nostr::Kind::TextNote, "test", [])
.custom_created_at(ts)
.sign_with_keys(keys)
.expect("signing should succeed")
}
#[test]
fn bg_state_dedup_first_event_accepted() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
let keys = nostr::Keys::generate();
let event = make_test_event(&keys, 1_000_000);
assert!(
state.record_event(channel_id, &event),
"first event should be accepted"
);
}
#[test]
fn bg_state_dedup_duplicate_rejected() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
let keys = nostr::Keys::generate();
let event = make_test_event(&keys, 1_000_000);
assert!(
state.record_event(channel_id, &event),
"first should be accepted"
);
assert!(
!state.record_event(channel_id, &event),
"duplicate should be rejected"
);
}
#[test]
fn bg_state_dedup_different_ids_both_accepted() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
// Two different keys → two different event IDs.
let keys1 = nostr::Keys::generate();
let keys2 = nostr::Keys::generate();
let event1 = make_test_event(&keys1, 1_000_000);
let event2 = make_test_event(&keys2, 1_000_001);
assert!(state.record_event(channel_id, &event1));
assert!(state.record_event(channel_id, &event2));
}
// ── BgState: last_seen tracking ───────────────────────────────────────────
#[test]
fn bg_state_last_seen_set_on_first_event() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
let keys = nostr::Keys::generate();
let event = make_test_event(&keys, 1_700_000);
state.record_event(channel_id, &event);
assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_700_000));
}
#[test]
fn bg_state_last_seen_advances_on_newer_event() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
let keys1 = nostr::Keys::generate();
let keys2 = nostr::Keys::generate();
let event1 = make_test_event(&keys1, 1_700_000);
let event2 = make_test_event(&keys2, 1_800_000);
state.record_event(channel_id, &event1);
state.record_event(channel_id, &event2);
assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_800_000));
}
#[test]
fn bg_state_last_seen_does_not_regress_on_older_event() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
let keys1 = nostr::Keys::generate();
let keys2 = nostr::Keys::generate();
let event_new = make_test_event(&keys1, 1_800_000);
let event_old = make_test_event(&keys2, 1_700_000);
state.record_event(channel_id, &event_new);
state.record_event(channel_id, &event_old);
// last_seen should remain at the higher timestamp
assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_800_000));
}
#[test]
fn bg_state_last_seen_independent_per_channel() {
let mut state = BgState::new();
let ch1 = Uuid::new_v4();
let ch2 = Uuid::new_v4();
let keys1 = nostr::Keys::generate();
let keys2 = nostr::Keys::generate();
let event1 = make_test_event(&keys1, 1_000_000);
let event2 = make_test_event(&keys2, 2_000_000);
state.record_event(ch1, &event1);
state.record_event(ch2, &event2);
assert_eq!(state.last_seen.get(&ch1).copied(), Some(1_000_000));
assert_eq!(state.last_seen.get(&ch2).copied(), Some(2_000_000));
}
/// Two-generation dedup: no amnesia window on rotation.
///
/// The old implementation cleared the entire set at 12_001, creating a gap
/// where all previously-seen IDs became eligible again. The new TwoGenDedup
/// rotates at SEEN_ID_LIMIT/2 = 6_000, keeping the previous generation so
/// IDs from both generations are still recognised as duplicates.
#[test]
fn bg_state_two_gen_dedup_no_amnesia_on_rotation() {
let mut dedup = TwoGenDedup::new(SEEN_ID_LIMIT);
// Fill current generation to the rotation threshold (limit/2 = 6_000).
// After inserting the 6_000th item, current rotates into previous.
let mut ids: Vec<String> = Vec::new();
for i in 0u64..6_000 {
let id = format!("{:0>64x}", i);
ids.push(id.clone());
dedup.insert(id);
}
// All 6_000 IDs were rotated into `previous`. `current` is now empty.
// They must still be recognised as duplicates.
for id in &ids {
assert!(
dedup.contains(id),
"rotated ID {id} should still be a duplicate"
);
}
// New IDs after rotation must be accepted.
let new_id = format!("{:0>64x}", 99_999u64);
assert!(
dedup.insert(new_id.clone()),
"new ID after rotation should be accepted"
);
assert!(
dedup.contains(&new_id),
"new ID should be found after insert"
);
}
#[test]
fn bg_state_two_gen_dedup_duplicate_rejected_across_generations() {
let mut dedup = TwoGenDedup::new(12);
// limit/2 = 6, so rotation happens at 6 inserts.
for i in 0u64..6 {
dedup.insert(format!("id-{i}"));
}
// id-0 is now in `previous` (rotated). Inserting it again must return false.
assert!(
!dedup.insert("id-0".to_string()),
"cross-generation duplicate must be rejected"
);
}
#[test]
fn bg_state_seen_ids_cleared_at_limit() {
// Compatibility test: BgState.record_event still deduplicates correctly
// after the TwoGenDedup rotation threshold is crossed.
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
// Insert SEEN_ID_LIMIT/2 synthetic IDs to trigger the first rotation.
for i in 0u64..(SEEN_ID_LIMIT as u64 / 2) {
state.seen_ids.insert(format!("{:0>64x}", i));
}
// The first generation has been rotated into `previous`. All IDs are
// still present across the two generations — no amnesia window.
assert!(
state
.seen_ids
.contains("0000000000000000000000000000000000000000000000000000000000000000"),
"first ID should still be recognised after rotation"
);
// A new real event should be accepted (not a duplicate).
let keys = nostr::Keys::generate();
let event = make_test_event(&keys, 1_000_000);
assert!(
state.record_event(channel_id, &event),
"new event after rotation should be accepted"
);
// The same event must be rejected as a duplicate.
assert!(
!state.record_event(channel_id, &event),
"duplicate event after rotation should be rejected"
);
}
// ── Bug 5: channel_dropped_since tracking ─────────────────────────────────
/// Test 8: channel_dropped_since records the OLDEST dropped timestamp.
///
/// Simulates the backpressure path directly on BgState:
/// - First drop at ts=1000 → entry is 1000
/// - Second drop at ts=2000 (later) → entry stays 1000 (min)
/// - Third drop at ts=500 (earlier) → entry updates to 500 (min)
#[test]
fn acp_records_channel_dropped_since_on_backpressure() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
// Simulate the backpressure path: record ts=1000.
let ts1: u64 = 1_000;
state
.channel_dropped_since
.entry(channel_id)
.and_modify(|d| *d = (*d).min(ts1))
.or_insert(ts1);
assert_eq!(
state.channel_dropped_since.get(&channel_id).copied(),
Some(1_000),
"first drop should record ts=1000"
);
// Later timestamp (2000) — entry should stay at 1000.
let ts2: u64 = 2_000;
state
.channel_dropped_since
.entry(channel_id)
.and_modify(|d| *d = (*d).min(ts2))
.or_insert(ts2);
assert_eq!(
state.channel_dropped_since.get(&channel_id).copied(),
Some(1_000),
"later drop should not overwrite earlier timestamp"
);
// Earlier timestamp (500) — entry should update to 500.
let ts3: u64 = 500;
state
.channel_dropped_since
.entry(channel_id)
.and_modify(|d| *d = (*d).min(ts3))
.or_insert(ts3);
assert_eq!(
state.channel_dropped_since.get(&channel_id).copied(),
Some(500),
"earlier drop should update entry to 500"
);
}
/// Test 9: reconnect since filter = min(last_seen, channel_dropped_since) - SINCE_SKEW_SECS.
///
/// With last_seen=1000 and channel_dropped_since=900, the effective since
/// passed to send_subscribe should be min(1000, 900) - SINCE_SKEW_SECS = 895.
#[test]
fn acp_reconnect_uses_dropped_since_for_replay() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
// Set up state: last_seen=1000, channel_dropped_since=900.
state.last_seen.insert(channel_id, 1_000);
state.channel_dropped_since.insert(channel_id, 900);
// Compute the since value the reconnect path would use.
let since = state.channel_since(&channel_id);
// The since passed to send_subscribe (which subtracts SINCE_SKEW_SECS internally).
assert_eq!(since, Some(900), "since should be min(1000, 900) = 900");
// After subtracting skew (as send_subscribe does), the REQ filter value is:
let req_since = since.unwrap().saturating_sub(SINCE_SKEW_SECS);
assert_eq!(
req_since, 895,
"REQ since filter should be 900 - {} = 895",
SINCE_SKEW_SECS
);
// Simulate clearing after resubscribe.
state.channel_dropped_since.remove(&channel_id);
assert!(
!state.channel_dropped_since.contains_key(&channel_id),
"channel_dropped_since should be cleared after resubscribe"
);
}
// ── Membership dedup regression tests (M4) ───────────────────────────
/// Membership dedup must NOT contaminate per-channel `last_seen`.
/// Using `record_event()` for membership notifications would update
/// `last_seen[channel_uuid]`, causing channel resubscribe to use a
/// membership timestamp as the `since` filter — skipping channel events.
/// The fix uses `seen_ids.insert()` directly.
#[test]
fn membership_dedup_does_not_touch_last_seen() {
let mut state = BgState::new();
let channel_id = Uuid::new_v4();
let keys = nostr::Keys::generate();
// Simulate: a channel event sets last_seen to 1000.
let event1 = make_test_event(&keys, 1_000);
assert!(state.record_event(channel_id, &event1));
assert_eq!(state.last_seen.get(&channel_id).copied(), Some(1_000));
// Simulate: a membership notification for the same channel at ts=2000.
// This should go through seen_ids only, NOT update last_seen.
let membership_event = make_test_event(&keys, 2_000);
let membership_id = membership_event.id.to_hex();
assert!(
state.seen_ids.insert(membership_id),
"membership event should be accepted by dedup"
);
// last_seen must still be 1000, not 2000.
assert_eq!(
state.last_seen.get(&channel_id).copied(),
Some(1_000),
"membership dedup must not contaminate last_seen"
);
}
/// On membership backpressure (TrySendError::Full), the dedup ID must
/// be removed from seen_ids so reconnect replay can re-deliver the event.
/// Without this, a dropped membership notification would be permanently
/// rejected as a duplicate on replay.
#[test]
fn membership_backpressure_removes_dedup_id() {
let mut state = BgState::new();
let keys = nostr::Keys::generate();
let event = make_test_event(&keys, 1_000);
let event_id_hex = event.id.to_hex();
// Insert into dedup (simulating the pre-try_send path).
assert!(state.seen_ids.insert(event_id_hex.clone()));
assert!(state.seen_ids.contains(&event_id_hex));
// Simulate backpressure: remove the ID (matching the production code).
state.seen_ids.remove(&event_id_hex);
// The ID should now be accepted again on replay.
assert!(
state.seen_ids.insert(event_id_hex),
"after backpressure removal, replay must be accepted"
);
}
}