From 5b8b9981d54f76a51aff4125e1a4686da3927201 Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:09:57 +0700 Subject: [PATCH] fix: on Windows, a reaped child closes the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every liveness decision in the harness reads EOF off the master: the "child is gone" refusals in send, resize and the mouse path, and the fast Error::Eof every wait returns instead of burning its timeout. On Unix that is the right signal — every slave descriptor closed is exactly "no one can read this", and a reaped child says nothing about a grandchild still holding the terminal. On Windows it never arrives: a pseudoconsole's output pipe stays open until the console itself is closed, whoever has exited. Five tests failed there the same way, with the child gone and the harness insisting someone was listening (#149). So where EOF cannot come, the reaped child is the signal. Each wait loop now ticks an ExitWatch alongside its poll: once try_wait reports the child gone and DRAIN_GRACE has passed with the terminal still open, the state is marked at EOF and the wait returns what it would have on Unix. wait_exit does the same after its drain grace expires. Both are gated on cfg!(windows) through one named constant, so Unix behaviour is byte-for-byte what it was; the whole suite there says so. Refs #149 Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- crates/termlens/src/terminal.rs | 71 ++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/termlens/src/terminal.rs b/crates/termlens/src/terminal.rs index 5895721..4c3c471 100644 --- a/crates/termlens/src/terminal.rs +++ b/crates/termlens/src/terminal.rs @@ -32,6 +32,57 @@ use crate::wait::{next_backoff, Expired, Monitor, INITIAL_BACKOFF, POLL_CAP}; /// holding the PTY open must not stall the wait. const DRAIN_GRACE: Duration = Duration::from_millis(500); +/// Whether the child being reaped is what closes the terminal. +/// +/// On Unix it is not: EOF on the master is the signal, because EOF means +/// every slave descriptor is closed — the exact condition under which no +/// one can read what we type — while a reaped child says nothing about a +/// grandchild that inherited the terminal and is still reading it. On +/// Windows the pseudoconsole's output pipe stays open until the console +/// itself is closed, whoever has exited, so EOF never arrives while a +/// `Terminal` is alive and the process is the only witness there is +/// (#149). There, a reaped child plus [`DRAIN_GRACE`] of drain is the +/// terminal closing. +const EXIT_CLOSES_THE_TERMINAL: bool = cfg!(windows); + +/// Watches the child from inside a wait, on the platform where nothing +/// else will say it is gone: once it is reaped and [`DRAIN_GRACE`] has +/// passed with the terminal still open, the terminal is marked closed and +/// every wait sees the same EOF it would have seen on Unix. +/// +/// A no-op where EOF is real. `try_wait` is a non-blocking probe of the +/// process handle, cheap enough for the poll-cap tick it rides on. +#[derive(Default)] +struct ExitWatch { + reaped_at: Option, +} + +impl ExitWatch { + fn tick( + &mut self, + child: &mut Box, + exit_status: &mut Option, + state: &mut EmuState, + ) { + if !EXIT_CLOSES_THE_TERMINAL || state.eof { + return; + } + if exit_status.is_none() { + match child.try_wait() { + Ok(Some(status)) => *exit_status = Some(ExitStatus::from_pty(&status)), + _ => return, + } + } + let reaped_at = *self.reaped_at.get_or_insert_with(Instant::now); + if reaped_at.elapsed() >= DRAIN_GRACE { + state.eof = true; + // A wait that skips unchanged state must see this change: EOF + // is a fact about the stream even when no byte carried it. + state.generation += 1; + } + } +} + /// How many distinct unanswered query shapes a terminal remembers for its /// diagnostics. The set is filled by the application under test — every /// distinct `CSI … n` is its own shape — so it is bounded; anything @@ -2742,6 +2793,9 @@ impl Terminal { /// slave descriptor is closed, which is exactly the condition under /// which no one can read — while a reaped child says nothing about a /// grandchild that inherited the terminal and is still reading it. + /// Windows is the exception, and a documented one: a pseudoconsole + /// never reports EOF while it is open, so there the reaped child is + /// the signal (see [`EXIT_CLOSES_THE_TERMINAL`]). fn ensure_deliverable(&mut self, what: &str) -> Result<()> { if !self.shared.lock().eof { return Ok(()); @@ -2879,7 +2933,11 @@ impl Terminal { const WHAT: &str = "the screen predicate to hold"; let deadline = Instant::now() + timeout; let mut seen_generation = None; + let mut exit_watch = ExitWatch::default(); let outcome = self.shared.wait_until(deadline, |state| { + // Ahead of the generation check: a child that exited changes + // nothing on the grid, and on Windows is the only EOF there is. + exit_watch.tick(&mut self.child, &mut self.exit_status, state); // Spurious wake (poll-cap tick, unrelated notify): the state is // unchanged, so the predicate's verdict is too. if seen_generation == Some(state.generation) { @@ -3041,7 +3099,9 @@ impl Terminal { let deadline = Instant::now() + timeout; let cursor = self.frame_cursor; let mut seen_frame = None; + let mut exit_watch = ExitWatch::default(); let outcome = self.shared.wait_until(deadline, |state| { + exit_watch.tick(&mut self.child, &mut self.exit_status, state); if let Some(failure) = state.emulator_failure() { return Some(Err(failure)); } @@ -3183,8 +3243,10 @@ impl Terminal { fn wait_idle_deadline(&mut self, quiet: Duration, timeout: Duration) -> Result<()> { let deadline = Instant::now() + timeout; + let mut exit_watch = ExitWatch::default(); let mut guard = self.shared.lock(); loop { + exit_watch.tick(&mut self.child, &mut self.exit_status, &mut guard); // Ahead of the EOF shortcut: a stream that stopped because the // emulator died is not a stream that went quiet. if let Some(failure) = guard.emulator_failure() { @@ -3372,7 +3434,9 @@ impl Terminal { let mut last = guard.peek_snapshot(); let mut since = guard.last_activity; let mut seen_generation = guard.generation; + let mut exit_watch = ExitWatch::default(); loop { + exit_watch.tick(&mut self.child, &mut self.exit_status, &mut guard); // Ahead of the EOF shortcut: a grid that stopped changing // because the emulator died has not settled. if let Some(failure) = guard.emulator_failure() { @@ -3531,11 +3595,16 @@ impl Terminal { if let Some(status) = self.child.try_wait().map_err(Error::Io)? { let status = ExitStatus::from_pty(&status); self.exit_status = Some(status.clone()); - let _ = self + let drained = self .shared .wait_until(Instant::now() + DRAIN_GRACE, |state| { state.eof.then_some(()) }); + // Where EOF was never going to come, the grace having passed + // with the child gone is the terminal closing. + if drained.is_err() && EXIT_CLOSES_THE_TERMINAL { + self.shared.mutate(|state| state.eof = true); + } return Ok(status); } let now = Instant::now();