From 7ed6d8622cc66d31d6ca731ef19276630ae5c87b Mon Sep 17 00:00:00 2001 From: ProofOfConcept Date: Fri, 6 Mar 2026 15:21:39 -0500 Subject: [PATCH] irc: client-side ping timeout and connection reliability - Send PING after 120s of silence, disconnect after 30s with no PONG - Reset backoff to base when a working connection drops (was registered) - Validate channel membership before sending to channels The ping timeout catches silent disconnects where the TCP connection stays open but OFTC has dropped us. Previously we'd sit "connected" indefinitely receiving nothing. --- src/bin/poc-daemon/modules/irc.rs | 47 ++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/bin/poc-daemon/modules/irc.rs b/src/bin/poc-daemon/modules/irc.rs index a93030c..7d7b727 100644 --- a/src/bin/poc-daemon/modules/irc.rs +++ b/src/bin/poc-daemon/modules/irc.rs @@ -23,6 +23,8 @@ use tracing::{error, info, warn}; const MAX_LOG_LINES: usize = 200; const RECONNECT_BASE_SECS: u64 = 5; const RECONNECT_MAX_SECS: u64 = 300; +const PING_INTERVAL_SECS: u64 = 120; +const PING_TIMEOUT_SECS: u64 = 30; /// Parsed IRC message. struct IrcMessage { @@ -208,8 +210,14 @@ async fn connection_loop( } } + // Reset backoff if we had a working connection (registered + // successfully before disconnecting) + let was_connected = state.borrow().connected; state.borrow_mut().connected = false; state.borrow_mut().writer = None; + if was_connected { + backoff = RECONNECT_BASE_SECS; + } // Persist current channel list to config { @@ -274,11 +282,39 @@ async fn register_and_read( } let mut buf = Vec::new(); + let mut ping_sent = false; + let mut deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PING_INTERVAL_SECS); loop { buf.clear(); - let n = reader.read_until(b'\n', &mut buf).await?; + + let read_result = tokio::select! { + result = reader.read_until(b'\n', &mut buf) => result, + _ = tokio::time::sleep_until(deadline) => { + if ping_sent { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "ping timeout — no response from server", + )); + } + info!("irc: no data for {}s, sending PING", PING_INTERVAL_SECS); + state.borrow_mut().send_raw("PING :keepalive").await?; + ping_sent = true; + deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PING_TIMEOUT_SECS); + continue; + } + }; + + let n = read_result?; if n == 0 { break; } + + // Any data from server resets the ping timer + ping_sent = false; + deadline = tokio::time::Instant::now() + + std::time::Duration::from_secs(PING_INTERVAL_SECS); + // IRC is not guaranteed UTF-8 — lossy conversion handles Latin-1 etc. let line = String::from_utf8_lossy(&buf).trim_end().to_string(); if line.is_empty() { continue; } @@ -471,6 +507,15 @@ pub async fn handle_command( return Err("usage: irc send ".into()); } let target = &args[0]; + if target.starts_with('#') { + let s = state.borrow(); + if !s.channels.iter().any(|c| c == target) { + return Err(format!( + "not in channel {target} (joined: {})", + s.channels.join(", ") + )); + } + } let msg = args[1..].join(" "); let nick = state.borrow().config.nick.clone(); state