From 58af1187fc49c01842ef6f8eed697c0ddd885a3e Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 08:57:38 +0000 Subject: [PATCH] engine: part orphaned-channel bots on a gossiped account removal When a peer's gossiped entry drops or takes over an account, the local node releases the channels that account founded locally (Local-scope channels can be founded by a Global, gossiped account). That cleanup runs outside the dispatch path, so a bot assigned to a released channel lingered until the next netburst. gossip_ingest now reconciles bots after an account is removed, matching the expiry sweep. --- src/engine/mod.rs | 26 ++++++++++++++++++++------ src/engine/tests.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 23ed619..3ed8bc5 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -458,12 +458,26 @@ impl Engine { } pub fn gossip_ingest(&mut self, entry: LogEntry) -> std::io::Result<()> { - // If ingesting a peer's entry removed an account a local session relied on - // (lost a conflict, or dropped elsewhere), clean up after it. - match self.db.ingest(entry)? { - Some(db::AccountChange::TakenOver(a)) => self.handle_account_gone(&a, "collided with another network and no longer belongs to you"), - Some(db::AccountChange::Dropped(a)) => self.handle_account_gone(&a, "was dropped"), - None => {} + // If ingesting a peer's entry removed an account a local session or channel + // relied on (lost a conflict, or dropped elsewhere), clean up after it. + let gone = match self.db.ingest(entry)? { + Some(db::AccountChange::TakenOver(a)) => { + self.handle_account_gone(&a, "collided with another network and no longer belongs to you"); + true + } + Some(db::AccountChange::Dropped(a)) => { + self.handle_account_gone(&a, "was dropped"); + true + } + None => false, + }; + // handle_account_gone may have released a channel that had a bot assigned. + // This runs outside the dispatch path (which reconciles for local + // commands), so reconcile here or the bot lingers in a released channel. + if gone { + for action in self.reconcile_bots() { + self.emit_irc(action); + } } Ok(()) } diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 38a1cad..447bb74 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -387,6 +387,51 @@ assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "realnick")), "must log into the current nick's account: {out:?}"); } + // Losing a name to a gossiped remote claim releases the local channels that + // account founded — and a bot assigned to one must part, not linger, even + // though this cleanup runs outside the dispatch path. + #[test] + fn gossip_takeover_parts_orphaned_channel_bot() { + use echo_botserv::BotServ; + use echo_nickserv::NickServ; + let path = std::env::temp_dir().join("echo-gossipbot.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "test"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + let mut e = Engine::new( + vec![ + Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }), + Box::new(BotServ { uid: "42SAAAAAD".into() }), + ], + db, + ); + e.set_sid("42S".into()); + let mut opers = std::collections::HashMap::new(); + opers.insert("alice".to_string(), Privs::default().with(echo_api::Priv::Admin)); + e.set_opers(opers); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() , ip: "0.0.0.0".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.db.register_channel("#hers", "alice").unwrap(); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() }); + let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #hers Bendy".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#hers")), "bot joins the channel"); + + // An earlier remote claim on alice arrives by gossip and wins the name. + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + e.set_irc_out(tx); + let winner = db::Account { + name: "alice".into(), email: None, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None, + }; + e.gossip_ingest(LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner)))).unwrap(); + + assert!(e.db.channel("#hers").is_none(), "orphaned channel released"); + let parted = std::iter::from_fn(|| rx.try_recv().ok()) + .any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#hers")); + assert!(parted, "the assigned bot parts the released channel"); + } + // Losing a registration conflict logs out the local session on that name and // notifies them over the services-initiated outbound path. #[test]