diff --git a/hive-agent/src/login_session.rs b/hive-agent/src/login_session.rs index e151fcbc..99c36df7 100644 --- a/hive-agent/src/login_session.rs +++ b/hive-agent/src/login_session.rs @@ -25,7 +25,7 @@ struct State { /// First URL-looking substring we saw in the output. Surface this on the /// web UI as the link the operator should open. url: Option, - /// Set when the child has exited. The web UI uses this to know whether + /// Set by `poll` when the child has exited. The web UI uses this to know whether /// the operator can still paste a code. finished: bool, /// Exit status note (e.g. "exited with code 0", "killed by signal 15"), @@ -209,10 +209,10 @@ async fn pump( // we'll miss it until EOF (acceptable for the URL surface — claude // prints the URL on its own line). match reader.read_line(&mut buf).await { - Ok(0) => { - state.lock().unwrap().finished = true; - break; - } + // EOF means this pipe closed, not that the child exited: only + // `poll` (via `try_wait`) may mark the session finished, or the + // next state render would drop a login that is still running. + Ok(0) => break, Ok(_) => { let mut s = state.lock().unwrap(); if s.url.is_none() @@ -225,9 +225,7 @@ async fn pump( } Err(e) => { tracing::warn!(error = ?e, %tag, "login pump read error"); - let mut s = state.lock().unwrap(); - s.finished = true; - s.exit_note = Some(format!("pump {tag} error: {e}")); + state.lock().unwrap().exit_note = Some(format!("pump {tag} error: {e}")); break; } } @@ -277,7 +275,18 @@ impl Drop for LoginSession { #[cfg(test)] mod tests { - use super::extract_url; + use super::{State, extract_url, pump}; + use std::sync::{Arc, Mutex}; + use tokio::io::BufReader; + + #[tokio::test] + async fn a_closed_output_pipe_does_not_finish_the_session() { + let state = Arc::new(Mutex::new(State::default())); + + pump(BufReader::new(tokio::io::empty()), state.clone(), "stdout").await; + + assert!(!state.lock().unwrap().finished); + } #[test] fn picks_first_https() {