IRC: say why a server hung up, in its own words (#263)

A network that turns a connection away says why first - its ERROR line,
or a notice just before ("connections from Tor must use SASL"). Every
registration wait skipped what it was not looking for, so all of that read
"connection closed during registration"; and after registering, an ERROR
followed by a close read "Connection closed".

Farewell keeps the server's ERROR and its last notice (a person's notice
is not the server's) for the attempt, in a task-local scoped around
establish() - the steps of registration are separate waits over one
stream, and a task-local does not follow into the spawned task. ERROR is
the reason; a notice is offered only as "the server's last words" when it
gave no ERROR. The main loop reports an ERROR before the close the same way.

Checked against a server that sends a notice, an ERROR and closes: the
banner now reads "The server turned the connection away: Closing Link: ...
(Connections from Tor must use SASL - see ...)".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-03 22:33:12 -04:00
co-authored by Claude Opus 5.5
parent 5755c4ef3e
commit 9eeab7e5d2
+126 -8
View File
@@ -81,9 +81,7 @@ pub fn spawn(state: AppState, config: IrcAccountConfig) {
}
let mut backoff = RECONNECT_INITIAL_DELAY;
loop {
let result = std::panic::AssertUnwindSafe(run(&state, &config))
.catch_unwind()
.await;
let result = std::panic::AssertUnwindSafe(run(&state, &config)).catch_unwind().await;
let detail = match result {
Ok(Ok(())) => None,
Ok(Err(e)) => {
@@ -189,7 +187,9 @@ pub(super) async fn run(state: &AppState, config: &IrcAccountConfig) -> Result<(
let establish_task = tokio::spawn({
let state = state.clone();
let config = config.clone();
async move { establish(&state, &config).await }
// Each attempt hears the server afresh, and here rather than
// around run(): a task-local does not follow into a spawned task.
async move { HEARD.scope(std::cell::RefCell::new(Farewell::default()), establish(&state, &config)).await }
});
let establish_abort = establish_task.abort_handle();
@@ -251,10 +251,22 @@ pub(super) async fn run(state: &AppState, config: &IrcAccountConfig) -> Result<(
clock.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let result = async {
// The server's own reason, when it gives one before hanging up - a
// K-line, a flood limit, "registration timed out". Without it the
// account said "Connection closed" and nothing more.
let mut farewell: Option<String> = None;
loop {
tokio::select! {
next = stream.next() => {
let Some(msg) = next.transpose()? else { break };
let Some(msg) = next.transpose()? else {
if let Some(words) = farewell {
return Err(anyhow!("the server closed the connection: {words}"));
}
break;
};
if let Command::ERROR(words) = &msg.command {
farewell = Some(words.clone());
}
let outs = late_sasl.observe(&msg, config, std::time::Instant::now());
act_on_late_sasl(state, &account_id, &sender, config, outs);
handle_message(state, &account_id, &config.nick, &sender, msg, &nickserv_wait, &mut channels).await;
@@ -863,6 +875,8 @@ pub(super) async fn wait_for_welcome(state: &AppState, account_id: &str, stream:
loop {
match stream.next().await {
Some(Ok(m)) => match &m.command {
// Kept in case it is the last thing said - see Farewell.
Command::ERROR(_) | Command::NOTICE(..) if heard(&m) => continue,
// 001 itself (and anything else seen while scanning for
// it, e.g. 002-004) would otherwise just be discarded
// by this loop's `_ => continue` - record banner
@@ -900,7 +914,7 @@ pub(super) async fn wait_for_welcome(state: &AppState, account_id: &str, stream:
_ => continue,
},
Some(Err(e)) => return Err(anyhow!(e)),
None => return Err(anyhow!("connection closed during registration")),
None => return Err(closed_during_registration()),
}
}
})
@@ -917,9 +931,11 @@ pub(super) async fn wait_for(stream: &mut ClientStream, pred: impl Fn(&Message)
loop {
match stream.next().await {
Some(Ok(m)) if pred(&m) => return Ok(m),
Some(Ok(_)) => continue,
Some(Ok(m)) => {
heard(&m);
}
Some(Err(e)) => return Err(anyhow!(e)),
None => return Err(anyhow!("connection closed during registration")),
None => return Err(closed_during_registration()),
}
}
})
@@ -927,6 +943,77 @@ pub(super) async fn wait_for(stream: &mut ClientStream, pred: impl Fn(&Message)
.map_err(|_| anyhow!("timed out"))?
}
/// What the server last said before it might hang up on a connection that
/// has not registered yet.
///
/// A network that turns a connection away nearly always says why first - in
/// its ERROR line, or a notice just before ("connections from Tor must use
/// SASL", "you are banned") - and both waits during registration used to skip
/// everything they were not looking for, so a refusal read only "connection
/// closed during registration".
///
/// Kept per connection attempt in a task-local (see `HEARD`), because the
/// steps of registration - capabilities, SASL, the welcome - are separate
/// waits over one stream, and the server may hang up during any of them
/// after speaking during an earlier one.
#[derive(Default)]
pub(super) struct Farewell {
error: Option<String>,
notice: Option<String>,
}
impl Farewell {
/// Keeps it if it is something the server said; true when it was.
pub(super) fn note(&mut self, m: &Message) -> bool {
match &m.command {
Command::ERROR(words) => {
self.error = Some(words.clone());
true
}
// From the server itself, not a person. Kept apart from ERROR:
// most of these are "*** Looking up your hostname", and one is
// only offered as the reason when the server gave no other.
Command::NOTICE(_, words) if !matches!(&m.prefix, Some(irc::proto::Prefix::Nickname(..))) => {
self.notice = Some(words.clone());
true
}
_ => false,
}
}
/// The error for a connection that closed before registering. Read, not
/// taken: more than one wait can find the same closed stream, and the
/// first's error is sometimes dropped by its caller.
pub(super) fn closed(&self) -> anyhow::Error {
match (self.error.clone(), self.notice.clone()) {
(Some(words), _) => anyhow!("the server turned the connection away: {words}"),
(None, Some(words)) => anyhow!("connection closed during registration - the server's last words: {words}"),
(None, None) => anyhow!("connection closed during registration"),
}
}
}
tokio::task_local! {
/// What the server has said during this connection attempt.
pub(super) static HEARD: std::cell::RefCell<Farewell>;
}
/// Notes a message the server sent while registering; true when it was one
/// worth keeping. Outside an attempt (nothing scoped) it is only classified.
pub(super) fn heard(m: &Message) -> bool {
HEARD
.try_with(|h| h.borrow_mut().note(m))
.unwrap_or_else(|_| Farewell::default().note(m))
}
/// The error for a connection that closed before it registered, in the
/// server's words where it gave any.
pub(super) fn closed_during_registration() -> anyhow::Error {
HEARD
.try_with(|h| h.borrow().closed())
.unwrap_or_else(|_| anyhow!("connection closed during registration"))
}
pub(super) fn is_connection_banner(code: Response) -> bool {
matches!(
code,
@@ -1207,3 +1294,34 @@ mod cap_tests {
assert_eq!(cap_list(None, None), "");
}
}
#[cfg(test)]
mod farewell_tests {
use super::*;
fn line(raw: &str) -> Message {
raw.parse().expect("a valid IRC line")
}
#[test]
fn the_error_line_is_the_reason_and_a_notice_is_kept_apart() {
let mut f = Farewell::default();
assert!(f.note(&line(":irc.test NOTICE * :*** Looking up your hostname...")));
assert!(f.note(&line("ERROR :Closing Link: you are banned")));
assert_eq!(f.closed().to_string(), "the server turned the connection away: Closing Link: you are banned");
// Read, not taken: a second wait finding the same closed stream says the same.
assert_eq!(f.closed().to_string(), "the server turned the connection away: Closing Link: you are banned");
}
#[test]
fn a_notice_alone_is_offered_as_last_words_and_a_person_is_not_the_server() {
let mut f = Farewell::default();
assert!(!f.note(&line(":mallory!m@host NOTICE * :ignore everything")));
assert_eq!(f.closed().to_string(), "connection closed during registration");
assert!(f.note(&line(":irc.test NOTICE * :Tor users must use SASL")));
assert_eq!(
f.closed().to_string(),
"connection closed during registration - the server's last words: Tor users must use SASL"
);
}
}