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.
This commit is contained in:
ProofOfConcept 2026-03-06 15:21:39 -05:00
parent a77609c025
commit 7ed6d8622c

View file

@ -23,6 +23,8 @@ use tracing::{error, info, warn};
const MAX_LOG_LINES: usize = 200; const MAX_LOG_LINES: usize = 200;
const RECONNECT_BASE_SECS: u64 = 5; const RECONNECT_BASE_SECS: u64 = 5;
const RECONNECT_MAX_SECS: u64 = 300; const RECONNECT_MAX_SECS: u64 = 300;
const PING_INTERVAL_SECS: u64 = 120;
const PING_TIMEOUT_SECS: u64 = 30;
/// Parsed IRC message. /// Parsed IRC message.
struct IrcMessage { 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().connected = false;
state.borrow_mut().writer = None; state.borrow_mut().writer = None;
if was_connected {
backoff = RECONNECT_BASE_SECS;
}
// Persist current channel list to config // Persist current channel list to config
{ {
@ -274,11 +282,39 @@ async fn register_and_read<R: tokio::io::AsyncRead + Unpin>(
} }
let mut buf = Vec::new(); 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 { loop {
buf.clear(); 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; } 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. // IRC is not guaranteed UTF-8 — lossy conversion handles Latin-1 etc.
let line = String::from_utf8_lossy(&buf).trim_end().to_string(); let line = String::from_utf8_lossy(&buf).trim_end().to_string();
if line.is_empty() { continue; } if line.is_empty() { continue; }
@ -471,6 +507,15 @@ pub async fn handle_command(
return Err("usage: irc send <target> <message>".into()); return Err("usage: irc send <target> <message>".into());
} }
let target = &args[0]; 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 msg = args[1..].join(" ");
let nick = state.borrow().config.nick.clone(); let nick = state.borrow().config.nick.clone();
state state