Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
Showing only changes of commit 3744884070 - Show all commits
+236 -4
View File
@@ -8,10 +8,12 @@
//! their own `WsMessage` variants and never appear as protobuf. //! their own `WsMessage` variants and never appear as protobuf.
//! //!
//! Reconnection uses exponential backoff (1,2,4,…,60 s) with jitter, and the //! Reconnection uses exponential backoff (1,2,4,…,60 s) with jitter, and the
//! whole loop is cancellable on stop. //! whole loop is cancellable on stop. A live session is kept honest by the
//! [`Liveness`] probe — without it a silently broken path parks the loop forever
//! (see that type's docs).
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@@ -27,11 +29,65 @@ use tracing::{debug, info, warn};
use crate::state::RelayState; use crate::state::RelayState;
/// How often the agent sends its **own** WS `Ping` on a live session.
const PING_INTERVAL_SECS: u64 = 20;
/// No inbound frame for this long ⇒ the session is dead; drop it and redial.
/// Two and a half of the relay's 30 s pings, comfortably under its own 120 s
/// idle close.
const IDLE_TIMEOUT_SECS: u64 = 75;
/// Per-session liveness knobs.
///
/// The probe exists because a purely *reactive* session cannot notice its own
/// death. We answer the relay's `Ping` with a `Pong` and otherwise send nothing
/// for long stretches, so when the path breaks silently — NAT rebinding, a
/// reverse proxy dropping its state — there are no unacked bytes on the socket
/// for the kernel to retransmit, no TCP error, and the relay's `Close` (it gives
/// up after 120 s of quiet) falls into the same hole. `stream.next()` then parks
/// forever on a socket to nobody, `is_connected()` keeps answering `true`, and
/// the reconnect schedule below — which works fine, it just never gets asked —
/// is never reached. Only a process restart clears it.
///
/// So both halves matter: `ping_every` keeps unacked bytes on the wire (the
/// relay pongs them back, which also refreshes *its* idle timer), and
/// `idle_after` turns silence into an `Err` and hands the session to the
/// reconnect path.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Liveness {
/// Interval between our outbound `Ping`s.
ping_every: Duration,
/// Silence tolerated before the session is declared dead.
idle_after: Duration,
}
// Hand-written: a derived `Default` would give a zero `ping_every` (a hot loop)
// and a zero `idle_after` (every session dead on arrival).
impl Default for Liveness {
fn default() -> Self {
Self {
ping_every: Duration::from_secs(PING_INTERVAL_SECS),
idle_after: Duration::from_secs(IDLE_TIMEOUT_SECS),
}
}
}
/// Run the reconnecting WS loop until `cancel` fires (relay-protocol.md §8). /// Run the reconnecting WS loop until `cancel` fires (relay-protocol.md §8).
pub(crate) async fn run_loop( pub(crate) async fn run_loop(
state: Arc<RelayState>,
outbound_rx: mpsc::UnboundedReceiver<Vec<u8>>,
cancel: CancellationToken,
) {
run_loop_with(state, outbound_rx, cancel, Liveness::default()).await
}
/// [`run_loop`] with the liveness knobs spelled out (tests use short ones so a
/// redial is observable in milliseconds).
async fn run_loop_with(
state: Arc<RelayState>, state: Arc<RelayState>,
mut outbound_rx: mpsc::UnboundedReceiver<Vec<u8>>, mut outbound_rx: mpsc::UnboundedReceiver<Vec<u8>>,
cancel: CancellationToken, cancel: CancellationToken,
liveness: Liveness,
) { ) {
let mut backoff_step: u32 = 0; let mut backoff_step: u32 = 0;
loop { loop {
@@ -39,7 +95,7 @@ pub(crate) async fn run_loop(
return; return;
} }
match connect_once(&state, &mut outbound_rx, &cancel).await { match connect_once(&state, &mut outbound_rx, &cancel, liveness).await {
Ok(()) => { Ok(()) => {
// Clean disconnect (cancelled or graceful): reset backoff. // Clean disconnect (cancelled or graceful): reset backoff.
backoff_step = 0; backoff_step = 0;
@@ -77,6 +133,7 @@ async fn connect_once(
state: &Arc<RelayState>, state: &Arc<RelayState>,
outbound_rx: &mut mpsc::UnboundedReceiver<Vec<u8>>, outbound_rx: &mut mpsc::UnboundedReceiver<Vec<u8>>,
cancel: &CancellationToken, cancel: &CancellationToken,
liveness: Liveness,
) -> Result<()> { ) -> Result<()> {
let url = state.relay_url(); let url = state.relay_url();
info!(crate_name = "skald-relay-client", %url, "connecting to relay"); info!(crate_name = "skald-relay-client", %url, "connecting to relay");
@@ -130,7 +187,13 @@ async fn connect_once(
}; };
sink.send(WsMessage::Binary(authorize.encode_to_vec().into())).await?; sink.send(WsMessage::Binary(authorize.encode_to_vec().into())).await?;
// 5. Main dispatch loop: outbound queue, inbound frames, WS-level Ping/Pong. // 5. Main dispatch loop: outbound queue, inbound frames, WS-level Ping/Pong,
// and the liveness probe.
let mut ping = tokio::time::interval(liveness.ping_every);
ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ping.tick().await; // consume the immediate first tick — we just handshook
let mut last_seen = Instant::now();
loop { loop {
tokio::select! { tokio::select! {
_ = cancel.cancelled() => { _ = cancel.cancelled() => {
@@ -138,6 +201,20 @@ async fn connect_once(
return Ok(()); return Ok(());
} }
// Liveness (see `Liveness`): probe the socket, and give up on a
// session that has gone quiet. Returning `Err` is what puts us back
// on the reconnect schedule instead of parking here forever.
_ = ping.tick() => {
let quiet = last_seen.elapsed();
if quiet > liveness.idle_after {
return Err(anyhow!(
"relay silent for {}s (no frame, not even a pong); redialing",
quiet.as_secs()
));
}
sink.send(WsMessage::Ping(Vec::new().into())).await?;
}
// Outbound: already-encoded protobuf frames queued by pairing / send // Outbound: already-encoded protobuf frames queued by pairing / send
// / revoke. The channel carries `Vec<u8>` ready to be shipped as a // / revoke. The channel carries `Vec<u8>` ready to be shipped as a
// binary WS frame. // binary WS frame.
@@ -151,6 +228,9 @@ async fn connect_once(
// Inbound: relay → agent frames. // Inbound: relay → agent frames.
maybe = stream.next() => { maybe = stream.next() => {
let Some(msg) = maybe else { return Ok(()) }; // stream ended let Some(msg) = maybe else { return Ok(()) }; // stream ended
// Any frame at all — data, Ping, Pong — proves the path is
// still there, which is the whole question the probe asks.
last_seen = Instant::now();
match msg? { match msg? {
WsMessage::Binary(data) => { WsMessage::Binary(data) => {
handle_incoming(state, &data).await; handle_incoming(state, &data).await;
@@ -360,3 +440,155 @@ mod tests {
} }
} }
} }
/// The liveness probe against a **silent** relay — the shape a black-holed path
/// leaves behind, where no `Close` and no TCP error ever arrive. The fake relay
/// completes the v2 handshake and then never speaks again; the agent has to work
/// out on its own that the session is dead, drop it, and redial. Before the
/// probe existed this parked forever and only a process restart cleared it.
#[cfg(test)]
mod net_tests {
use super::*;
use std::net::SocketAddr;
use skald_relay_common::proto::v2::{AuthOk, Challenge};
use sqlx::SqlitePool;
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};
use crate::db;
use crate::identity::Identity;
use crate::state::StateConfig;
/// Same seed on both sides so the `AuthOk` carries the namespace the agent
/// expects (a mismatch is a different failure than the one under test).
const SEED: [u8; 32] = [0x42; 32];
/// What the harness reports about the agent's dialling behaviour.
#[derive(Debug)]
enum Event {
/// A TCP connection was accepted.
Accepted,
/// That connection reached EOF — i.e. the agent hung up.
HungUp,
}
/// A relay that handshakes and then goes mute.
async fn spawn_silent_relay(ns_raw: [u8; 32]) -> (String, mpsc::UnboundedReceiver<Event>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr: SocketAddr = listener.local_addr().unwrap();
let (tx, rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
while let Ok((tcp, _)) = listener.accept().await {
let tx = tx.clone();
let _ = tx.send(Event::Accepted);
tokio::spawn(async move {
silent_session(tcp, ns_raw).await;
let _ = tx.send(Event::HungUp);
});
}
});
(format!("ws://{addr}/v1/ws"), rx)
}
/// Challenge → read the agent's `Auth` → `AuthOk` → total silence, until the
/// agent closes the socket.
///
/// The silence is why the tail reads the **raw TCP stream** instead of
/// `ws.next()`: tungstenite answers an inbound `Ping` with an automatic
/// `Pong` flushed on the next read, which would keep the agent's `last_seen`
/// fresh and defeat the very condition being simulated. For the same reason
/// this asserts nothing about the probe frames themselves — the observable
/// contract is that the agent gives up and comes back.
async fn silent_session(tcp: TcpStream, ns_raw: [u8; 32]) {
let mut ws = tokio_tungstenite::accept_async(tcp).await.expect("ws accept");
let challenge = RelayFrame {
frame: Some(Frame::Challenge(Challenge {
nonce: prost::bytes::Bytes::from(vec![0x5A; 32]),
})),
};
ws.send(WsMessage::Binary(challenge.encode_to_vec().into())).await.unwrap();
// The agent's `Auth` is the next binary frame. It signs a nonce we chose
// ourselves, so there is nothing here worth verifying.
while let Some(Ok(msg)) = ws.next().await {
if matches!(msg, WsMessage::Binary(_)) {
break;
}
}
let ok = RelayFrame {
frame: Some(Frame::AuthOk(AuthOk {
namespace_id: prost::bytes::Bytes::copy_from_slice(&ns_raw),
})),
};
ws.send(WsMessage::Binary(ok.encode_to_vec().into())).await.unwrap();
// From here on we are a black hole: drain bytes, answer nothing.
let tcp = ws.get_mut();
let mut scratch = [0u8; 1024];
while let Ok(n) = tcp.read(&mut scratch).await {
if n == 0 {
break; // agent hung up
}
}
}
async fn make_state(relay_url: String) -> Arc<RelayState> {
let path = std::env::temp_dir()
.join(format!("relay-cli-liveness-{}.db", std::process::id()));
let pool = SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path.display()))
.await
.unwrap();
db::init(&pool).await.unwrap();
let (events_tx, _) = tokio::sync::broadcast::channel(16);
Arc::new(RelayState::new(
Identity::from_seed(&SEED),
Arc::new(pool),
StateConfig { relay_url, pairing_ttl: 300 },
events_tx,
))
}
async fn next(rx: &mut mpsc::UnboundedReceiver<Event>) -> Event {
tokio::time::timeout(Duration::from_secs(10), rx.recv())
.await
.expect("timed out waiting on the relay harness")
.expect("relay harness gone")
}
#[tokio::test]
async fn silent_relay_is_dropped_and_redialed() {
let ns_raw = Identity::from_seed(&SEED).namespace_id_raw();
let (url, mut events) = spawn_silent_relay(ns_raw).await;
let state = make_state(url).await;
let (out_tx, out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
state.set_outbound(out_tx);
let cancel = CancellationToken::new();
// Production values scaled down ~200×; the ratio is what matters.
let liveness = Liveness {
ping_every: Duration::from_millis(100),
idle_after: Duration::from_millis(400),
};
let task = {
let state = Arc::clone(&state);
let cancel = cancel.clone();
tokio::spawn(async move { run_loop_with(state, out_rx, cancel, liveness).await })
};
assert!(matches!(next(&mut events).await, Event::Accepted), "agent should dial");
assert!(
matches!(next(&mut events).await, Event::HungUp),
"agent parked on a mute socket instead of giving up on it",
);
assert!(
matches!(next(&mut events).await, Event::Accepted),
"agent dropped the dead session but never dialled again",
);
cancel.cancel();
let _ = tokio::time::timeout(Duration::from_secs(5), task).await;
}
}