From a3dc7b521f340fc49a31c7aeed081bc923c443c1 Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 18 Aug 2026 22:24:19 +0000 Subject: [PATCH] =?UTF-8?q?test:=20deterministic=20two-node=20S2S=20conver?= =?UTF-8?q?gence=20simulator=20=E2=80=94=20real=20handshake=20+=20join/par?= =?UTF-8?q?t/rejoin/FJOIN-TS-arbitration=20driven=20through=20actual=20cod?= =?UTF-8?q?e=20paths,=20proptest-randomised=20churn=20asserts=20both=20sid?= =?UTF-8?q?es=20always=20converge;=20extract=20Server::part=20so=20the=20s?= =?UTF-8?q?im=20and=20the=20PART=20command=20share=20one=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/channels.rs | 43 ++++++ src/coremods/core_channel.rs | 36 +---- src/lib.rs | 2 + src/s2s_sim.rs | 271 +++++++++++++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 35 deletions(-) create mode 100644 src/s2s_sim.rs diff --git a/src/channels.rs b/src/channels.rs index bf84e80..ac7225a 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -974,6 +974,49 @@ impl Server { self.events.push_back(Hook::Join(uid, key)); } + /// A local user leaves `target` (original case): broadcast the PART, tell linked + /// servers, drop membership, and cull the channel if it's now empty. Returns + /// false (a no-op) if they weren't on it — the caller emits ERR_NOTONCHANNEL. + pub fn part(&mut self, uid: Uid, target: &str, reason: &str) -> bool { + let key = target.to_ascii_lowercase(); + let on = self + .users + .get(&uid) + .map(|u| u.channels.contains(&key)) + .unwrap_or(false); + if !on { + return false; + } + let prefix = self.users[&uid].prefix(); + let line = if reason.is_empty() { + format!(":{prefix} PART {target}") + } else { + format!(":{prefix} PART {target} :{reason}") + }; + // +D delayjoin: a still-hidden member's PART is shown only to themselves + let hidden = self + .channels + .get(&key) + .and_then(|c| c.members.get(&uid)) + .map(|m| m.hidden) + .unwrap_or(false); + if hidden { + self.send(uid, line); + } else { + self.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part + } + self.propagate_part(uid, target, reason); // tell linked servers + if let Some(ch) = self.channels.get_mut(&key) { + ch.members.remove(&uid); + } + if let Some(u) = self.users.get_mut(&uid) { + u.channels.remove(&key); + } + self.channels.retain(|_, c| c.keep_alive()); + self.events.push_back(Hook::Part(uid, key, reason.to_string())); + true + } + /// Rename channel `oldkey` (an existing lowercase key) to display name /// `newname`, preserving all state — membership, modes, topic, bans, TS. The /// channel object is rekeyed in the table and every local member's channel set diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs index 0db9fbd..57ec9f9 100644 --- a/src/coremods/core_channel.rs +++ b/src/coremods/core_channel.rs @@ -434,47 +434,13 @@ impl Command for Part { fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { let reason = params.get(1).cloned().unwrap_or_default(); for target in params[0].split(',').filter(|x| !x.is_empty()) { - let key = target.to_ascii_lowercase(); - let on = s - .users - .get(&uid) - .map(|u| u.channels.contains(&key)) - .unwrap_or(false); - if !on { + if !s.part(uid, target, &reason) { s.numeric( uid, ERR_NOTONCHANNEL, &format!("{target} :You're not on that channel"), ); - continue; } - let prefix = s.users[&uid].prefix(); - let line = if reason.is_empty() { - format!(":{prefix} PART {target}") - } else { - format!(":{prefix} PART {target} :{reason}") - }; - // +D delayjoin: a still-hidden member's PART is shown only to themselves - let hidden = s - .channels - .get(&key) - .and_then(|c| c.members.get(&uid)) - .map(|m| m.hidden) - .unwrap_or(false); - if hidden { - s.send(uid, line.clone()); - } else { - s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part - } - s.propagate_part(uid, target, &reason); // tell linked servers - if let Some(ch) = s.channels.get_mut(&key) { - ch.members.remove(&uid); - } - if let Some(u) = s.users.get_mut(&uid) { - u.channels.remove(&key); - } - s.channels.retain(|_, c| c.keep_alive()); - s.events.push_back(Hook::Part(uid, key, reason.clone())); } CmdResult::Ok } diff --git a/src/lib.rs b/src/lib.rs index 8b592b7..d21e046 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,8 @@ pub mod server; pub mod socketengine; pub mod tls; pub mod tls_rustls; +#[cfg(test)] +mod s2s_sim; pub mod users; pub mod watch; pub mod websocket; diff --git a/src/s2s_sim.rs b/src/s2s_sim.rs new file mode 100644 index 0000000..7ebc9d7 --- /dev/null +++ b/src/s2s_sim.rs @@ -0,0 +1,271 @@ +//! Deterministic two-node S2S simulation: build two `Server`s in-process, link them +//! over the real spanning-tree handshake, drive client operations through the actual +//! `join`/`part`/mode/nick paths, pump the resulting S2S lines between the nodes, and +//! assert both sides *converge* to the same channel state. No sockets, no threads. +//! +//! This targets the bug class that has historically escaped to production — join/part +//! churn, FJOIN timestamp arbitration, mode/topic sync — by exercising it with both +//! scripted scenarios and proptest-randomised sequences. + +use std::sync::atomic::AtomicU64; +use std::sync::mpsc::{self, Receiver}; +use std::sync::Arc; + +use crate::config::{Config, LinkBlock}; +use crate::extensible::Extensible; +use crate::map::HashSet; +use crate::message; +use crate::server::Server; +use crate::socketengine::OutSink; +use crate::users::{Caps, User, UserFlags}; +use crate::Uid; + +const LINK_UID: Uid = 90_000; // the link connection's local uid (distinct from users) + +/// One simulated server plus the channel that captures everything it sends to its +/// single peer link. +struct Node { + srv: Server, + link_rx: Receiver, + sid: String, + next: u64, // per-node uuid suffix counter +} + +impl Node { + fn new(sid: &str, name: &str, peer_name: &str, peer_ip: &str) -> Node { + let mut cfg = Config::default(); + cfg.servername = name.to_string(); + cfg.sid = sid.to_string(); + cfg.serverdesc = "sim".to_string(); + cfg.links = vec![LinkBlock { + name: peer_name.to_string(), + ip: peer_ip.to_string(), + port: 7000, + password: "pw".to_string(), + autoconnect: false, + }]; + let (tx, _rx) = mpsc::channel(); + let srv = Server::new(cfg, tx, Arc::new(AtomicU64::new(1))); + let (_dead_tx, link_rx) = mpsc::channel(); // replaced in `link` + Node { + srv, + link_rx, + sid: sid.to_string(), + next: 0, + } + } + + /// Insert a fully-registered local user; its uuid carries this node's SID like a + /// real one, so the peer keys it consistently. Returns its uid. + fn add_user(&mut self, uid: Uid, nick: &str) -> Uid { + self.next += 1; + let uuid = format!("{}{:06}", self.sid, self.next); + let (tx, _rx) = mpsc::channel(); + self.srv.users.insert( + uid, + User { + uid, + uuid, + nick: nick.to_string(), + ident: "u".to_string(), + realname: "real".to_string(), + host: "host".to_string(), + cloak: String::new(), + vhost: None, + secure: false, + certfp: None, + account: None, + signon: 0, + nick_ts: 0, + addr: "127.0.0.1:1".parse().unwrap(), + port: 6667, + registered: true, + dns_pending: false, + ident_pending: false, + auth_pending: false, + waitpong: None, + class: None, + pass: None, + deferred: Vec::new(), + cap: false, + cap_302: false, + caps: Caps::default(), + sasl_mech: None, + channels: HashSet::default(), + watch: Vec::new(), + monitor: Vec::new(), + silence: Vec::new(), + accept: Vec::new(), + quitting: None, + flags: UserFlags::default(), + last_active: 0, + ping_sent: false, + ext: Extensible::default(), + out: OutSink::Thread(tx), + sock: None, + }, + ); + self.srv.nick_index.insert(nick.to_ascii_lowercase(), uid); + uid + } +} + +/// Link two nodes over the real handshake, then pump until the burst settles. +fn link(a: &mut Node, b: &mut Node) { + let (a_tx, a_rx) = mpsc::channel(); + let (b_tx, b_rx) = mpsc::channel(); + a.link_rx = a_rx; + b.link_rx = b_rx; + // a dials b (outbound → sends SERVER); b accepts (inbound → waits). + a.srv.add_link( + LINK_UID, + "10.0.0.2:7000".parse().unwrap(), + OutSink::Thread(a_tx), + None, + true, + ); + b.srv.add_link( + LINK_UID, + "10.0.0.1:7000".parse().unwrap(), + OutSink::Thread(b_tx), + None, + false, + ); + pump(a, b); +} + +/// Exchange every queued S2S line between the two nodes until neither has more. +fn pump(a: &mut Node, b: &mut Node) { + for _ in 0..1000 { + let mut moved = false; + let a_out: Vec = a.link_rx.try_iter().collect(); + for line in &a_out { + moved = true; + if let Some(msg) = message::parse(line) { + b.srv.on_link(LINK_UID, &msg); + } + } + let b_out: Vec = b.link_rx.try_iter().collect(); + for line in &b_out { + moved = true; + if let Some(msg) = message::parse(line) { + a.srv.on_link(LINK_UID, &msg); + } + } + if !moved { + return; + } + } + panic!("S2S pump did not converge (message loop?)"); +} + +/// The network-wide membership of `key` as a set of user uuids (local members mapped +/// through their uuid, remote members by their key) — the canonical view to compare. +fn members(n: &Node, key: &str) -> std::collections::BTreeSet { + let mut set = std::collections::BTreeSet::new(); + if let Some(ch) = n.srv.channels.get(key) { + for &uid in ch.members.keys() { + if let Some(u) = n.srv.users.get(&uid) { + set.insert(u.uuid.clone()); + } + } + for uuid in ch.rmembers.keys() { + set.insert(uuid.clone()); + } + } + set +} + +fn chan_ts(n: &Node, key: &str) -> Option { + n.srv.channels.get(key).map(|c| c.created) +} +fn chan_modes(n: &Node, key: &str) -> Option { + n.srv.channels.get(key).map(|c| c.modes.render(false)) +} + +/// Assert the two nodes agree on `key`'s membership, timestamp and modes. +fn assert_converged(a: &Node, b: &Node, key: &str) { + assert_eq!(members(a, key), members(b, key), "membership diverged on {key}"); + assert_eq!(chan_ts(a, key), chan_ts(b, key), "channel TS diverged on {key}"); + assert_eq!(chan_modes(a, key), chan_modes(b, key), "channel modes diverged on {key}"); +} + +#[test] +fn basic_join_propagates_and_converges() { + let mut a = Node::new("1AA", "a.test", "b.test", "10.0.0.2"); + let mut b = Node::new("2BB", "b.test", "a.test", "10.0.0.1"); + let au = a.add_user(1, "ann"); + let bu = b.add_user(1, "bob"); + a.srv.join(au, "#c", None); + b.srv.join(bu, "#c", None); + link(&mut a, &mut b); + // both users end up known network-wide in #c on both nodes + assert_converged(&a, &b, "#c"); + assert_eq!(members(&a, "#c").len(), 2, "both users present after link"); +} + +#[test] +fn part_rejoin_churn_stays_converged() { + let mut a = Node::new("1AA", "a.test", "b.test", "10.0.0.2"); + let mut b = Node::new("2BB", "b.test", "a.test", "10.0.0.1"); + let au = a.add_user(1, "ann"); + let bu = b.add_user(1, "bob"); + a.srv.join(au, "#c", None); + link(&mut a, &mut b); + // ann parts and rejoins repeatedly; bob joins in the middle. Never diverge. + for i in 0..8 { + a.srv.part(au, "#c", "churn"); + pump(&mut a, &mut b); + if i == 3 { + b.srv.join(bu, "#c", None); + pump(&mut a, &mut b); + } + a.srv.join(au, "#c", None); + pump(&mut a, &mut b); + assert_converged(&a, &b, "#c"); + } + assert!(members(&a, "#c").contains(&a.srv.users[&au].uuid.clone())); +} + +#[test] +fn fjoin_ts_arbitration_lower_ts_wins() { + let mut a = Node::new("1AA", "a.test", "b.test", "10.0.0.2"); + let mut b = Node::new("2BB", "b.test", "a.test", "10.0.0.1"); + let au = a.add_user(1, "ann"); + let bu = b.add_user(1, "bob"); + // Both create #c independently BEFORE linking, at different timestamps. + a.srv.join(au, "#c", None); + b.srv.join(bu, "#c", None); + a.srv.channels.get_mut("#c").unwrap().created = 100; // older — should win + b.srv.channels.get_mut("#c").unwrap().created = 200; + link(&mut a, &mut b); + assert_converged(&a, &b, "#c"); + assert_eq!(chan_ts(&a, "#c"), Some(100), "the lower timestamp wins the channel"); + assert_eq!(members(&a, "#c").len(), 2, "both members merged"); +} + +proptest::proptest! { + // Randomised churn: an arbitrary interleaving of join/part on both nodes must + // always leave the two sides converged on #c. + #[test] + fn random_churn_converges(ops in proptest::collection::vec(0u8..4, 0..40)) { + let mut a = Node::new("1AA", "a.test", "b.test", "10.0.0.2"); + let mut b = Node::new("2BB", "b.test", "a.test", "10.0.0.1"); + let au = a.add_user(1, "ann"); + let bu = b.add_user(1, "bob"); + link(&mut a, &mut b); + for op in ops { + match op { + 0 => a.srv.join(au, "#c", None), + 1 => { a.srv.part(au, "#c", "x"); } + 2 => b.srv.join(bu, "#c", None), + _ => { b.srv.part(bu, "#c", "x"); } + } + pump(&mut a, &mut b); + } + // drain any residue and compare + pump(&mut a, &mut b); + proptest::prop_assert_eq!(members(&a, "#c"), members(&b, "#c")); + proptest::prop_assert_eq!(chan_ts(&a, "#c"), chan_ts(&b, "#c")); + } +}