Files
ngit-grasp/tests/common/flapping_relay.rs
T
DanConwayDev c3bcd132bc fix(sync): reject stale connection setup success
Production soak of bccbb54 showed nostr-pub.wellorder.net disconnect at 16:30:57 while its connection worker was still fetching NIP-11. The delayed result was accepted at 16:31:19, marked Healthy, and started fresh sync against an already dead SDK session. Because the event loop was spawned after the disconnect notification, that lifecycle could also remain falsely connected.

Revalidate the SDK relay status when a successful setup result reaches the sync actor. A peer that vanished after the handshake but before setup completed is handled through the existing failed-attempt path, which restores Disconnected state and records backoff without starting subscriptions or an event loop.

A deterministic dual-protocol fixture coordinates WebSocket teardown with the NIP-11 request and proves the stale result is rejected. The earlier flapping fixture now includes a short established dwell so it continues to model post-setup failure rather than this new setup race.

Correctness assumes RelayStatus::Connected is the authoritative final setup gate. A disconnect immediately after that gate remains handled by the normal event-loop notification path. NIP-11 timeout policy, reconnect pacing, and broader lifecycle serialization are excluded.

Validated with cargo test --lib (650 passed), cargo test --test sync (89 passed, 1 ignored), both focused reconnect lifecycle scenarios, and nix build .#ngit-grasp.
2026-08-07 16:49:30 +00:00

120 lines
4.5 KiB
Rust

//! WebSocket relay fixture that accepts normal setup and then drops the session.
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use futures_util::StreamExt;
use tokio::net::TcpListener;
use tokio::sync::{oneshot, Notify};
use tokio_tungstenite::tungstenite::Message;
/// Relay-shaped endpoint for exercising reconnect health accounting.
pub struct FlappingRelay {
url: String,
connected_at: Arc<Mutex<Vec<Instant>>>,
connection_observed: Arc<Notify>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<tokio::task::JoinHandle<()>>,
}
impl FlappingRelay {
/// Start an endpoint that drops each WebSocket after the first REQ frame.
pub async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("FlappingRelay failed to bind");
let port = listener.local_addr().expect("flapping local_addr").port();
let connected_at = Arc::new(Mutex::new(Vec::new()));
let connection_observed = Arc::new(Notify::new());
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
let observed = connected_at.clone();
let notify = connection_observed.clone();
let handle = tokio::spawn(async move {
loop {
tokio::select! {
accepted = listener.accept() => {
let Ok((stream, _)) = accepted else { break };
let observed = observed.clone();
let notify = notify.clone();
tokio::spawn(async move {
let Ok(mut websocket) = tokio_tungstenite::accept_async(stream).await
else {
// NIP-11 HTTP probes are expected to fail this
// deliberately WebSocket-only fixture.
return;
};
observed.lock().expect("flapping timestamps poisoned").push(Instant::now());
notify.notify_waiters();
while let Some(message) = websocket.next().await {
let Ok(message) = message else { break };
if matches!(message, Message::Text(ref text) if text.contains("\"REQ\"")) {
// Dropping the socket after observable normal
// setup and a short established dwell models
// the production peer reset. The dwell also
// distinguishes this fixture from a peer that
// vanishes during connection setup.
tokio::time::sleep(Duration::from_millis(100)).await;
return;
}
}
});
}
_ = &mut shutdown_rx => break,
}
}
});
Self {
url: format!("ws://127.0.0.1:{port}"),
connected_at,
connection_observed,
shutdown_tx: Some(shutdown_tx),
handle: Some(handle),
}
}
pub fn url(&self) -> &str {
&self.url
}
/// Wait for `count` successful WebSocket sessions using notifications,
/// bounded by `timeout`.
pub async fn wait_for_connections(&self, count: usize, timeout: Duration) -> Vec<Instant> {
tokio::time::timeout(timeout, async {
loop {
let notified = self.connection_observed.notified();
let timestamps = self
.connected_at
.lock()
.expect("flapping timestamps poisoned")
.clone();
if timestamps.len() >= count {
return timestamps;
}
notified.await;
}
})
.await
.expect("recovered relay did not make the expected connection attempts")
}
pub async fn stop(mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.handle.take() {
let _ = handle.await;
}
}
}
impl Drop for FlappingRelay {
fn drop(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
}
}