152 lines
6 KiB
Rust
152 lines
6 KiB
Rust
|
|
//! Spec §2 (relay-session tunnel state, gap 2) and §3 (per-transition
|
||
|
|
//! relay events, gap 3).
|
||
|
|
//!
|
||
|
|
//! After this work every snapshot a node emits carries an explicit
|
||
|
|
//! answer to "is my tunnel to my relay healthy right now," separate
|
||
|
|
//! from "do my peer connections through that tunnel work." When the
|
||
|
|
//! transport library does not expose enough state to populate the
|
||
|
|
//! field natively, the snapshot says so explicitly via the
|
||
|
|
//! `status_source` discriminator, and the field name appears in
|
||
|
|
//! `Tier2IrohState::api_gaps` so the bundle reader is never left
|
||
|
|
//! guessing whether `unknown` means "tunnel is unknown" vs "we
|
||
|
|
//! couldn't ask."
|
||
|
|
//!
|
||
|
|
//! For §3: every relay-state flip produces an event on the event
|
||
|
|
//! stream. `RelaySessionStateChanged` is the authoritative source for
|
||
|
|
//! "did the tunnel flap" — a grep for the variant across the bundle
|
||
|
|
//! tells you which nodes flapped and when.
|
||
|
|
|
||
|
|
use distribution::diagnostics::event::Event;
|
||
|
|
use distribution::diagnostics::snapshot::{Tier2IrohState, Tier2Peer, Tier2RelaySession};
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn relay_session_carries_status_and_status_source_discriminator() {
|
||
|
|
// Bundle-reader contract from spec §2: every snapshot must carry
|
||
|
|
// an explicit (status, status_source) pair so absent is
|
||
|
|
// distinguishable from "we couldn't ask."
|
||
|
|
let unknown = Tier2RelaySession {
|
||
|
|
relay_url: None,
|
||
|
|
status: "unknown".to_string(),
|
||
|
|
status_source: "derived".to_string(),
|
||
|
|
status_changed_at_ms: None,
|
||
|
|
status_entered_at_ms: Some(100),
|
||
|
|
last_send_at_ms: None,
|
||
|
|
last_recv_at_ms: None,
|
||
|
|
tx_bytes_total: None,
|
||
|
|
rx_bytes_total: None,
|
||
|
|
};
|
||
|
|
let json = serde_json::to_value(&unknown).unwrap();
|
||
|
|
assert_eq!(json["status"], "unknown");
|
||
|
|
assert_eq!(json["status_source"], "derived");
|
||
|
|
let back: Tier2RelaySession = serde_json::from_value(json).unwrap();
|
||
|
|
assert_eq!(back.status, "unknown");
|
||
|
|
assert_eq!(back.status_source, "derived");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn relay_tunnel_status_is_an_api_gap_until_iroh_populates_it_natively() {
|
||
|
|
// §2 cross-references §6: when the tunnel status is derived (not
|
||
|
|
// reported), its canonical name must appear in `api_gaps` so the
|
||
|
|
// bundle reader knows the value is synthesized.
|
||
|
|
let derived = Tier2RelaySession {
|
||
|
|
relay_url: Some("https://relay.example/".into()),
|
||
|
|
status: "connected".into(),
|
||
|
|
status_source: "derived".into(),
|
||
|
|
..Tier2RelaySession::default()
|
||
|
|
};
|
||
|
|
let gaps = Tier2IrohState::compute_api_gaps_full(&[], Some(&derived));
|
||
|
|
assert!(
|
||
|
|
gaps.iter().any(|g| g == "RelayTunnel.status"),
|
||
|
|
"derived status must keep RelayTunnel.status in the gap list; got {gaps:?}",
|
||
|
|
);
|
||
|
|
|
||
|
|
let native = Tier2RelaySession {
|
||
|
|
relay_url: Some("https://relay.example/".into()),
|
||
|
|
status: "connected".into(),
|
||
|
|
status_source: "iroh".into(),
|
||
|
|
..Tier2RelaySession::default()
|
||
|
|
};
|
||
|
|
let gaps_native = Tier2IrohState::compute_api_gaps_full(&[], Some(&native));
|
||
|
|
assert!(
|
||
|
|
!gaps_native.iter().any(|g| g == "RelayTunnel.status"),
|
||
|
|
"natively-sourced status must drop RelayTunnel.status from gaps; got {gaps_native:?}",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn computed_gaps_combine_peer_and_relay_candidates() {
|
||
|
|
// §1 cross-cut: a bundle reader sees one gap list per snapshot
|
||
|
|
// covering both per-peer and per-relay-tunnel candidates.
|
||
|
|
let no_data = Tier2IrohState::compute_api_gaps_full(&[], None);
|
||
|
|
assert!(
|
||
|
|
no_data.iter().any(|g| g.contains("RemoteInfo.")),
|
||
|
|
"with no peer evidence we must list peer-side gaps; got {no_data:?}",
|
||
|
|
);
|
||
|
|
assert!(
|
||
|
|
no_data.iter().any(|g| g.contains("RelayTunnel.")),
|
||
|
|
"with no relay evidence we must list relay-side gaps; got {no_data:?}",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn relay_session_state_changed_event_round_trips_through_serde() {
|
||
|
|
// §3 acceptance: the event must be greppable in the bundle. That
|
||
|
|
// means it must round-trip through serde with its discriminator
|
||
|
|
// intact.
|
||
|
|
let ev = Event::RelaySessionStateChanged {
|
||
|
|
relay_url: Some("https://relay.example/".into()),
|
||
|
|
from_status: "connecting".into(),
|
||
|
|
to_status: "connected".into(),
|
||
|
|
reason: Some("watcher-update".into()),
|
||
|
|
};
|
||
|
|
let json = serde_json::to_value(&ev).unwrap();
|
||
|
|
assert_eq!(json["type"], "RelaySessionStateChanged");
|
||
|
|
assert_eq!(json["from_status"], "connecting");
|
||
|
|
assert_eq!(json["to_status"], "connected");
|
||
|
|
let back: Event = serde_json::from_value(json).unwrap();
|
||
|
|
match back {
|
||
|
|
Event::RelaySessionStateChanged { from_status, to_status, .. } => {
|
||
|
|
assert_eq!(from_status, "connecting");
|
||
|
|
assert_eq!(to_status, "connected");
|
||
|
|
}
|
||
|
|
_ => panic!("expected RelaySessionStateChanged"),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn old_snapshot_without_relay_session_still_parses() {
|
||
|
|
// Spec §1 (additive evolution): a Tier2IrohState built by old
|
||
|
|
// code that knew nothing about `relay_session` parses cleanly
|
||
|
|
// through the new struct.
|
||
|
|
let json = serde_json::json!({
|
||
|
|
"peers": [],
|
||
|
|
"metrics": [],
|
||
|
|
"connection_cache": [],
|
||
|
|
"api_gaps": [],
|
||
|
|
"scraped_at_ms": 1
|
||
|
|
});
|
||
|
|
let parsed: Tier2IrohState = serde_json::from_value(json).unwrap();
|
||
|
|
assert!(parsed.relay_session.is_none(), "old bundle: relay_session absent");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn peer_gap_logic_unchanged_for_existing_callers() {
|
||
|
|
// Sanity: the new compute_api_gaps_full default-callsite (with
|
||
|
|
// relay=None) must still surface conn_type when no peer
|
||
|
|
// populates it natively. Catches accidental regressions in the
|
||
|
|
// shared candidate-list logic that the §6 work depends on.
|
||
|
|
let derived_peer = Tier2Peer {
|
||
|
|
peer_node_id_hex: "dd".repeat(32),
|
||
|
|
conn_type: Some(distribution::diagnostics::ConnType::Direct),
|
||
|
|
conn_type_source: Some("derived".into()),
|
||
|
|
latency_ms: None,
|
||
|
|
last_used_ms: None,
|
||
|
|
last_received_ms: None,
|
||
|
|
direct_addresses: Vec::new(),
|
||
|
|
relay_urls: Vec::new(),
|
||
|
|
addr_sources: None,
|
||
|
|
};
|
||
|
|
let gaps = Tier2IrohState::compute_api_gaps(&[derived_peer]);
|
||
|
|
assert!(gaps.iter().any(|g| g == "RemoteInfo.conn_type"));
|
||
|
|
}
|