From 58dcda40822c121ba679de14b6a6150bbc05197a Mon Sep 17 00:00:00 2001 From: Blfrg Date: Wed, 2 Sep 2026 14:04:15 -0600 Subject: [PATCH 1/2] Don't fail read_line when the cursor-position query times out at prompt init `initialize_prompt_position` asks the terminal where the cursor is before painting a prompt. crossterm waits a fixed 2s for the reply and then fails; a terminal busy repainting, a multiplexer briefly holding the reply, or a slow remote link is enough to trip it. That is a transient condition, not a broken terminal -- but this was the only query site that still propagated the error, so it aborted the whole `read_line`, and a host application typically treats that as fatal (reubeno/brush#1329: one late reply exits the interactive shell). Every other `cursor_position()` call in the painter already degrades to its last-known row. Do the same here: print a newline so the prompt at least starts at column 0 on a row of its own, keep the row as `Stale` so the next paint's drift check asks again (and itself tolerates no answer), and carry on. With no row known at all, assume the bottom of the screen: the drift check only re-anchors when the cursor turns out to be *above* the cached row, so a guess that errs high self-heals on the next answered query, whereas row 0 would never be corrected. The query is split out of `initialize_prompt_position` into `anchor_prompt` so it can be unit-tested without a tty; the test writers already return an error from `cursor_position()`, which is exactly the case being handled. Verified against brush (nushell/reedline 0.49.0 + this change, host with no retry of its own): a pty harness that withholds the reply to one query no longer ends the session. --- src/painting/painter.rs | 60 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/painting/painter.rs b/src/painting/painter.rs index 4731e9caa..7a6d2014f 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -497,7 +497,39 @@ impl Painter { size } }; - let prompt_selector = select_prompt_row(suspended_state, self.stdout.cursor_position()?); + self.anchor_prompt(suspended_state) + } + + /// Establishes the prompt's start row for a new line editor invocation by + /// asking the terminal where the cursor is. + fn anchor_prompt(&mut self, suspended_state: Option<&PainterSuspendedState>) -> Result<()> { + // The terminal may not answer the cursor-position query in time + // (crossterm gives it a fixed 2s): a terminal busy repainting, a + // multiplexer briefly holding the reply, a slow remote link. That is + // a transient condition, not a broken terminal, and this was the only + // query site that still aborted `read_line` on it -- every other one + // degrades to its last-known row. Do the same: print a newline so the + // prompt at least starts at column 0 on a row of its own, keep the + // row as `Stale` so the next paint's drift check asks again (and + // itself tolerates no answer), and carry on. + // + // With no row known at all, assume the bottom of the screen: the + // drift check only re-anchors when the cursor turns out to be + // *above* the cached row, so a guess that errs high self-heals on + // the next answered query, whereas row 0 would never be corrected. + let position = match self.stdout.cursor_position() { + Ok(position) => position, + Err(_) => { + self.print_crlf()?; + let row = match self.prompt_start_row { + PromptStartRow::Verified(row) | PromptStartRow::Stale(row) => row, + PromptStartRow::Unverified => self.screen_height().saturating_sub(1), + }; + self.prompt_start_row = PromptStartRow::Stale(row); + return Ok(()); + } + }; + let prompt_selector = select_prompt_row(suspended_state, position); let new_row = match prompt_selector { PromptRowSelector::UseExistingPrompt { start_row } => start_row, PromptRowSelector::MakeNewPrompt { new_row } => { @@ -1529,6 +1561,32 @@ mod tests { ); } + // Test writers never answer the cursor-position query (see + // `W::cursor_position`), which is exactly the terminal-didn't-reply case. + // Anchoring must then degrade rather than fail the whole `read_line`. + #[test] + fn test_anchor_prompt_without_answer_keeps_last_known_row_as_stale() { + let mut painter = Painter::new(W::capture()); + painter.terminal_size = (20, 10); + painter.prompt_start_row.mark_verified(4); + + painter.anchor_prompt(None).unwrap(); + + assert_eq!(painter.prompt_start_row, PromptStartRow::Stale(4)); + assert_eq!(painter.stdout.captured(), b"\r\n"); + } + + #[test] + fn test_anchor_prompt_without_answer_and_no_row_assumes_bottom() { + let mut painter = Painter::new(W::capture()); + painter.terminal_size = (20, 10); + + painter.anchor_prompt(None).unwrap(); + + assert_eq!(painter.prompt_start_row, PromptStartRow::Stale(9)); + assert_eq!(painter.stdout.captured(), b"\r\n"); + } + fn base_snapshot() -> RenderSnapshot { RenderSnapshot { screen_width: 20, From 56cb1c225844bcb950adbcecab2d86b7114879f5 Mon Sep 17 00:00:00 2001 From: Blfrg Date: Wed, 9 Sep 2026 15:48:03 -0600 Subject: [PATCH 2/2] fix(painter): always guess the bottom row when the anchor query goes unanswered Reusing the last-known row was wrong: `anchor_prompt` has already printed a newline by then, so in the common REPL case the true cursor sits below the cached row. The drift check only re-anchors upward, so an undershooting guess is never repaired, and `clear_from_anchor` then wipes the output above the prompt. The bottom of the screen is the only guess that always lands on the repairable side. Review feedback on #1202. --- src/painting/painter.rs | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/painting/painter.rs b/src/painting/painter.rs index 7a6d2014f..1722be5ca 100644 --- a/src/painting/painter.rs +++ b/src/painting/painter.rs @@ -507,25 +507,26 @@ impl Painter { // (crossterm gives it a fixed 2s): a terminal busy repainting, a // multiplexer briefly holding the reply, a slow remote link. That is // a transient condition, not a broken terminal, and this was the only - // query site that still aborted `read_line` on it -- every other one - // degrades to its last-known row. Do the same: print a newline so the - // prompt at least starts at column 0 on a row of its own, keep the - // row as `Stale` so the next paint's drift check asks again (and - // itself tolerates no answer), and carry on. + // query site that still aborted `read_line` on it. Degrade instead: + // print a newline so the prompt at least starts at column 0 on a row + // of its own, keep the row as `Stale` so the next paint's drift check + // asks again (and itself tolerates no answer), and carry on. // - // With no row known at all, assume the bottom of the screen: the - // drift check only re-anchors when the cursor turns out to be - // *above* the cached row, so a guess that errs high self-heals on - // the next answered query, whereas row 0 would never be corrected. + // The substitute row must be the bottom of the screen, never the + // last-known row. The drift check only re-anchors when the cursor + // turns out to be *above* the cached row, so a guess can only be + // repaired if it errs high. The newline just printed has already + // moved the cursor past the last-known row in the common REPL case, + // and `clear_from_anchor` erases down from the anchor, so an + // undershooting guess wipes the output above the prompt with no way + // to recover. No row is greater than the bottom, so it is the only + // guess that always lands on the repairable side. let position = match self.stdout.cursor_position() { Ok(position) => position, Err(_) => { self.print_crlf()?; - let row = match self.prompt_start_row { - PromptStartRow::Verified(row) | PromptStartRow::Stale(row) => row, - PromptStartRow::Unverified => self.screen_height().saturating_sub(1), - }; - self.prompt_start_row = PromptStartRow::Stale(row); + self.prompt_start_row = + PromptStartRow::Stale(self.screen_height().saturating_sub(1)); return Ok(()); } }; @@ -1563,16 +1564,19 @@ mod tests { // Test writers never answer the cursor-position query (see // `W::cursor_position`), which is exactly the terminal-didn't-reply case. - // Anchoring must then degrade rather than fail the whole `read_line`. + // Anchoring must then degrade rather than fail the whole `read_line`, + // and must guess the bottom row: the drift check only repairs a guess + // that errs high, and the newline printed first has already moved the + // cursor past any last-known row. #[test] - fn test_anchor_prompt_without_answer_keeps_last_known_row_as_stale() { + fn test_anchor_prompt_without_answer_assumes_bottom_over_last_known_row() { let mut painter = Painter::new(W::capture()); painter.terminal_size = (20, 10); painter.prompt_start_row.mark_verified(4); painter.anchor_prompt(None).unwrap(); - assert_eq!(painter.prompt_start_row, PromptStartRow::Stale(4)); + assert_eq!(painter.prompt_start_row, PromptStartRow::Stale(9)); assert_eq!(painter.stdout.captured(), b"\r\n"); }