fix(tui): terminal escapes ride the writer queue and teardown is bounded

A terminal that stopped draining the pty parked the writer thread inside
its tty write while it held the stderr lock; the next inline stderr write
from the event loop (mouse toggle, notification, kitty clear) deadlocked
the loop, and /quit hung on the unbounded writer join and the teardown
writes.

EscapeWriter queues out-of-band escapes on the frame writer: the mouse
capture toggle, notifications (title, progress, protocol), the auth
cursor, gboom keyboard flags, the wrap clipboard request, and the kitty
clears (queued per agent view and merged into the frame's post-flush,
nested subagent views included). The $EDITOR/$PAGER handoff aborts when
the writer does not drain. WriterThread::join_within and the teardown
run under a 2 s grace; after a stall fd 2 is silenced, the exit hints,
relaunch and update prints are skipped, and the panic hook keeps only its
log report.

Ported from upstream 1.0.19 (grok-build EscapeWriter, WriterJoin,
run_bounded_teardown), extended to the remaining inline writes.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
雷电芽衣 committed 2026-09-13 09:08:09 -04:00
1 parent 91f14112bf
commit 4512df79e6
17 files changed
+560 -165

No files matched your search

@@ -1231,6 +1231,17 @@ mod cancel_turn_mouse_tests {
use crate::views::modal::{CancelTurnChoice, CancelTurnViewState};
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
#[test]
fn queued_post_flush_escapes_drain_in_order() {
let mut agent = make_agent();
agent.queue_post_flush(crate::terminal::overlay::PostFlush::plain("a".into()));
agent.queue_post_flush(crate::terminal::overlay::PostFlush::plain("b".into()));
let drained = agent
.take_pending_post_flush()
.map(|p| p.as_str().to_owned());
assert_eq!(drained.as_deref(), Some("ab"));
assert!(agent.take_pending_post_flush().is_none());
}
fn make_agent() -> AgentView {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
AgentView::new(
@@ -22,10 +22,7 @@ impl AgentView {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
// Kitty images outlive the dropped viewer state; clear before close.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
self.image_viewer = None;
self.image_load_rx = None;
// The viewer's decoded/re-encoded overlay image (tens of MB
@@ -539,10 +536,7 @@ impl AgentView {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
// Kitty images outlive the dropped viewer state; clear before close.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
self.video_viewer = None;
// The viewer's pre-extracted frame set (~50–300 MB for a
// typical clip) just dropped; return the pages to the OS.
@@ -569,10 +563,7 @@ impl AgentView {
match gboom.handle_key(key) {
crate::gboom::GboomKeyOutcome::Close => {
// Kitty images outlive the dropped game state; clear before close.
kigi_shell::util::with_locked_stderr(|stderr| {
let clear = PostFlush::from(overlay::clear_kitty());
let _ = clear.write_to(stderr);
});
self.queue_post_flush(PostFlush::from(overlay::clear_kitty()));
self.gboom = None;
}
crate::gboom::GboomKeyOutcome::Changed => {}
@@ -1003,6 +1003,8 @@ pub struct AgentView {
pub(crate) edit_hl: Option<crate::app::edit_highlight_worker::EditHlRuntime>,
/// Whether any inline media is currently placed on screen.
pub(crate) inline_media_active: bool,
/// Escapes queued for the next frame's post-flush write (see `queue_post_flush`).
pub(crate) pending_post_flush: Option<crate::terminal::overlay::PostFlush>,
/// Image IDs that were placed on screen last frame. Used to detect
/// images that scrolled off and need their Kitty placements cleared.
pub(crate) last_placed_ids: HashSet<u32>,
@@ -605,13 +605,32 @@ impl AgentView {
false,
&mut Vec::new(),
);
child_post_flush = post_flush;
// The child's own queued clears (inline image ids) go out before its frame escapes.
child_post_flush = match (child_view.take_pending_post_flush(), post_flush) {
(Some(mut pending), Some(frame)) => {
pending.append(frame);
Some(pending)
}
(pending, frame) => pending.or(frame),
};
}
(None, child_post_flush)
}
pub fn should_show_tip(&mut self) -> bool {
false
}
/// Queue escapes for the frame's post-flush write: the event loop never writes stderr inline.
pub(crate) fn queue_post_flush(&mut self, escapes: crate::terminal::overlay::PostFlush) {
match self.pending_post_flush.as_mut() {
Some(pending) => pending.append(escapes),
None => self.pending_post_flush = Some(escapes),
}
}
pub(crate) fn take_pending_post_flush(
&mut self,
) -> Option<crate::terminal::overlay::PostFlush> {
self.pending_post_flush.take()
}
/// `area` is the screen region assigned to this agent view.
/// When a tracing overlay is visible, this is smaller than `f.area()`.
///
@@ -669,20 +688,17 @@ impl AgentView {
|| self.active_modal.is_some())
{
self.inline_media_active = false;
kigi_shell::util::with_locked_stderr(|stderr| {
for &id in self.inline_media_ids.values() {
let clear = crate::terminal::image::clear_kitty_image(id);
let _ = std::io::Write::write_all(stderr, clear.as_bytes());
}
});
let mut clears = String::new();
for &id in self.inline_media_ids.values() {
clears.push_str(&crate::terminal::image::clear_kitty_image(id));
}
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(clears));
self.inline_media_ids.clear();
self.inline_media_iterm_emitted.clear();
}
if let Some(ref child_sid) = self.active_subagent.clone() {
if let Some(esc) = self.take_own_inline_media_clear_escapes() {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = std::io::Write::write_all(stderr, esc.as_bytes());
});
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(esc));
}
return self.draw_subagent_fullscreen(
&child_sid.clone(),
@@ -695,9 +711,7 @@ impl AgentView {
);
}
if let Some(esc) = self.take_subagent_inline_media_clear_escapes() {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = std::io::Write::write_all(stderr, esc.as_bytes());
});
self.queue_post_flush(crate::terminal::overlay::PostFlush::plain(esc));
}
let appearance = self.scrollback.appearance().clone();
let layout_cfg = &appearance.scrollback.layout;
@@ -185,6 +185,7 @@ impl AgentView {
mermaid: None,
edit_hl: None,
inline_media_active: false,
pending_post_flush: None,
last_placed_ids: HashSet::new(),
last_terminal_size: (0, 0),
terminal_size_stale: false,
+28 -12
View File
@@ -954,6 +954,8 @@ pub struct AppView {
/// Whether the pager uses fullscreen (alt-screen) or inline mode.
/// Set from the resolved terminal state at startup.
pub(crate) screen_mode: super::ScreenMode,
/// Out-of-band escapes ride the frame queue, never an inline stderr write.
pub(crate) escape_writer: crate::render::draw::EscapeWriter,
/// Agent Dashboard state. `Some(_)` only when the dashboard view
/// is active (`active_view == AgentDashboard`) or recently closed.
/// Held outside the `ActiveView` discriminant because `DashboardState`
@@ -1022,7 +1024,10 @@ impl AppView {
scroll_state: MouseScrollState::default(),
scroll_config: ScrollConfig::from_settings(),
appearance: AppearanceConfig::default(),
notification_service: NotificationService::new(Default::default()),
notification_service: NotificationService::new(
Default::default(),
crate::render::draw::EscapeWriter::disconnected(),
),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
@@ -1054,6 +1059,7 @@ impl AppView {
welcome_auth_fallback_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(
@@ -3095,9 +3101,8 @@ impl AppView {
.hyperlink_capabilities()
.osc22_cursor
{
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = crossterm::execute!(stderr, crate::terminal::SetDefaultCursor);
});
self.escape_writer
.emit_command(crate::terminal::SetDefaultCursor);
}
}
let want_mouse_off = self.auth_show_raw_url
@@ -3106,17 +3111,15 @@ impl AppView {
&& matches!(self.auth_state, AuthState::Authenticating { .. });
if want_mouse_off && !self.auth_mouse_disabled {
self.auth_mouse_disabled = true;
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = crossterm::execute!(stderr, crossterm::event::DisableMouseCapture);
});
self.escape_writer
.emit_command(crossterm::event::DisableMouseCapture);
#[cfg(windows)]
super::win_native_selection::enable_native_selection();
super::MOUSE_CAPTURE_ENABLED.store(false, std::sync::atomic::Ordering::Release);
} else if !want_mouse_off && self.auth_mouse_disabled {
self.auth_mouse_disabled = false;
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = crossterm::execute!(stderr, crossterm::event::EnableMouseCapture);
});
self.escape_writer
.emit_command(crossterm::event::EnableMouseCapture);
super::MOUSE_CAPTURE_ENABLED.store(true, std::sync::atomic::Ordering::Release);
for agent in self.agents.values_mut() {
agent.set_sticky_toast_recursive(None);
@@ -3450,6 +3453,8 @@ impl AppView {
panel.render(full_area, f.buffer_mut());
}
let (cursor_pos, post_flush) = result;
let post_flush =
Self::merge_post_flush(agent.take_pending_post_flush(), post_flush);
let has_cloud = false;
if has_cloud || self.import_claude_modal.is_some() {
link_spans.clear();
@@ -3505,7 +3510,7 @@ impl AppView {
dashboard,
|inner, buf| {
if let Some(agent) = agents.get_mut(&agent_id) {
agent.draw(
let (cursor, post_flush) = agent.draw(
inner,
buf,
registry,
@@ -3517,6 +3522,13 @@ impl AppView {
bundle_state,
false,
link_spans,
);
(
cursor,
Self::merge_post_flush(
agent.take_pending_post_flush(),
post_flush,
),
)
} else {
(None, None)
@@ -4304,7 +4316,10 @@ pub(crate) mod tests {
scroll_state: MouseScrollState::default(),
scroll_config: ScrollConfig::default(),
appearance: AppearanceConfig::default(),
notification_service: NotificationService::new(Default::default()),
notification_service: NotificationService::new(
Default::default(),
crate::render::draw::EscapeWriter::disconnected(),
),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
@@ -4373,6 +4388,7 @@ pub(crate) mod tests {
welcome_auth_fallback_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(
@@ -506,13 +506,14 @@ pub(in crate::app::dispatch) fn dispatch_toggle_mouse_capture(app: &mut AppView)
"active_view": format!("{:?}", app.active_view),
})),
);
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = if enable {
crossterm::execute!(stderr, crossterm::event::EnableMouseCapture)
} else {
crossterm::execute!(stderr, crossterm::event::DisableMouseCapture)
};
});
// Through the writer queue, never inline: see EscapeWriter.
if enable {
app.escape_writer
.emit_command(crossterm::event::EnableMouseCapture);
} else {
app.escape_writer
.emit_command(crossterm::event::DisableMouseCapture);
}
// On legacy conhost, DisableMouseCapture restores the *pre-capture* stdin
// mode, which may itself have QuickEdit off (per-window profile or a
// stale mode from a crashed run) — assert it so "mouse off" actually
@@ -463,6 +463,7 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
None,
wrap_text.as_deref(),
None,
&app.escape_writer,
);
let effects = drain_clipboard_target(&target, app);
maybe_show_x11_primary_paste_hint(
@@ -81,7 +81,10 @@ fn test_app() -> AppView {
scroll_state: crate::input::mouse::MouseScrollState::default(),
scroll_config: crate::input::mouse::ScrollConfig::default(),
appearance: crate::appearance::AppearanceConfig::default(),
notification_service: crate::notifications::NotificationService::new(Default::default()),
notification_service: crate::notifications::NotificationService::new(
Default::default(),
crate::render::draw::EscapeWriter::disconnected(),
),
pending_notification_escapes: None,
deferred_notification: None,
tracing_rx: None,
@@ -153,6 +156,7 @@ fn test_app() -> AppView {
welcome_auth_fallback_rect: None,
auth_show_raw_url: false,
auth_mouse_disabled: false,
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
session_picker_entries: None,
session_picker_loading: false,
session_picker_state: crate::views::picker::PickerState::with_mode(
+40 -15
View File
@@ -222,7 +222,7 @@ fn suspend_for_child(
reader_parked: &std::sync::atomic::AtomicBool,
input_rx: &mut tokio::sync::mpsc::UnboundedReceiver<crossterm::event::Event>,
run_child: impl FnOnce(),
) -> Option<(u16, u16)> {
) -> std::io::Result<Option<(u16, u16)>> {
use std::sync::atomic::Ordering;
// Pause the reader thread, then wait for a FRESH park (reader provably out of
// crossterm) so the main thread is the sole poll/read caller; bounded so a
@@ -237,8 +237,13 @@ fn suspend_for_child(
// fullscreen, before LeaveAlternateScreen below — mirroring teardown's
// "no late frame after LeaveAlternateScreen" drain). Bounded like the
// reader park so a wedged pty can't hang the suspend.
// The alt-screen writes below take the stderr lock a parked writer still holds: abort instead.
if !writer_sync.wait_drained(Duration::from_millis(750)) {
tracing::warn!("suspend: frame writer not drained within 750ms; proceeding");
input_paused.store(false, Ordering::Release);
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"terminal writer did not drain before suspend",
));
}
// Pre-child cursor probe (minimal only — minimal's startup already proved
// this terminal answers CPR). Reader is parked, so the reply is ours.
@@ -274,7 +279,7 @@ fn suspend_for_child(
// lands in the channel, not the tty.
while input_rx.try_recv().is_ok() {}
input_paused.store(false, Ordering::Release);
moved_cursor
Ok(moved_cursor)
}
/// Restore the inline/minimal live region after a tty-taking child exited.
@@ -351,7 +356,7 @@ fn run_pending_suspends(
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
let moved_cursor = suspend_for_child(
let moved_cursor = match suspend_for_child(
app.screen_mode,
&writer_sync,
input_paused,
@@ -360,7 +365,15 @@ fn run_pending_suspends(
|| {
let _ = std::process::Command::new(&editor).arg(&path).status();
},
);
) {
Ok(moved) => moved,
Err(error) => {
tracing::warn!(%error, "editor suspend skipped");
app.pending_agents_modal_refresh = None;
app.show_toast("Editor not opened: the terminal is not draining output");
return;
}
};
if let Some(tab) = app.pending_agents_modal_refresh.take()
&& let ActiveView::Agent(id) = app.active_view
&& let Some(agent) = app.agents.get_mut(&id)
@@ -387,7 +400,7 @@ fn run_pending_suspends(
.filter(|p| !p.trim().is_empty())
.unwrap_or_else(|| "less".to_string());
let writer_sync = terminal.backend_mut().writer_mut().writer_sync().clone();
let moved_cursor = suspend_for_child(
let moved_cursor = match suspend_for_child(
app.screen_mode,
&writer_sync,
input_paused,
@@ -431,7 +444,15 @@ fn run_pending_suspends(
.status();
}
},
);
) {
Ok(moved) => moved,
Err(error) => {
tracing::warn!(%error, "pager suspend skipped");
let _ = std::fs::remove_file(&path);
app.show_toast("Transcript not opened: the terminal is not draining output");
return;
}
};
let _ = std::fs::remove_file(&path);
// The pager owned the screen; re-anchor if it printed inline (cat) and
// repaint the full viewport rather than diffing against a screen state
@@ -492,6 +513,7 @@ pub(crate) async fn run(
// poll the leader roster (see the roster-poll arm below).
app.leader_mode = connection.leader_status_rx.is_some();
app.screen_mode = term_state.screen_mode;
app.escape_writer = terminal.backend_mut().writer().escape_writer();
// Agent/dashboard prompts pick the mode up at their creation sites
// (`apply_app_scoped_gates` / `ensure_dashboard_state`); the welcome prompt
// already exists, so inject here.
@@ -737,12 +759,15 @@ pub(crate) async fn run(
cursor: compat.cursor.sessions,
};
// Load notification config from [ui.notifications] in config.toml.
if let Some(ref raw) = effective_config {
app.notification_service = crate::notifications::NotificationService::new(
crate::notifications::load_notification_config(raw),
);
}
// Load notification config from [ui.notifications] in config.toml; the
// service always gets the live escape writer, never AppView::new's stub.
app.notification_service = crate::notifications::NotificationService::new(
effective_config
.as_ref()
.map(crate::notifications::load_notification_config)
.unwrap_or_default(),
app.escape_writer.clone(),
);
// Full layered resolve (env/requirements/remote may beat plain `[ui]`).
crate::appearance::cache::set_show_thinking_blocks(
@@ -1378,7 +1403,7 @@ pub(crate) async fn run(
let want_gboom_keyboard = app.gboom_active();
if want_gboom_keyboard {
if !gboom_keyboard_pushed {
super::push_gboom_keyboard_flags();
super::push_gboom_keyboard_flags(&app.escape_writer);
gboom_keyboard_pushed = true;
}
// Only the active game receives release events; any other open
@@ -1386,7 +1411,7 @@ pub(crate) async fn run(
// no key down when reopened after a tab/view switch.
app.gboom_release_backgrounded_games();
} else if gboom_keyboard_pushed {
super::pop_gboom_keyboard_flags();
super::pop_gboom_keyboard_flags(&app.escape_writer);
gboom_keyboard_pushed = false;
// No game is the active input target now (switched to a non-game
// view); clear every game's holds for the same reason.
+199 -37
View File
@@ -73,23 +73,19 @@ static GBOOM_KEYBOARD_PUSHED: AtomicBool = AtomicBool::new(false);
/// release events — required to track several keys held at once. No-op
/// unless the Kitty keyboard protocol is active. Balanced by
/// [`pop_gboom_keyboard_flags`] (and by `restore_terminal` on teardown).
pub(crate) fn push_gboom_keyboard_flags() {
pub(crate) fn push_gboom_keyboard_flags(writer: &crate::render::draw::EscapeWriter) {
if !kitty_flags_pushed() || GBOOM_KEYBOARD_PUSHED.swap(true, Ordering::AcqRel) {
return;
}
let flags = event::KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| event::KeyboardEnhancementFlags::REPORT_EVENT_TYPES
| event::KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PushKeyboardEnhancementFlags(flags));
});
writer.emit_command(event::PushKeyboardEnhancementFlags(flags));
}
/// Pop the extra keyboard layer pushed by [`push_gboom_keyboard_flags`].
pub(crate) fn pop_gboom_keyboard_flags() {
pub(crate) fn pop_gboom_keyboard_flags(writer: &crate::render::draw::EscapeWriter) {
if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
});
writer.emit_command(event::PopKeyboardEnhancementFlags);
}
}
/// Tracks whether mouse capture (the five DEC modes enabled by
@@ -175,6 +171,7 @@ pub(crate) const MOUSE_OFF_HINT_PROMPT: &str =
/// `write()` to stderr / the pty fd, keeping the tokio event loop free
/// from pty back-pressure (e.g. when Ghostty is busy with another pane).
pub use crate::render::draw::PagerTerminal;
use crate::render::draw::WriterJoin;
/// Whether the pager uses the alternate screen (fullscreen) or stays inline.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ScreenMode {
@@ -305,14 +302,19 @@ fn resolve_hunk_tracker_mode(
/// pager skips the welcome screen and immediately loads that session (replaying
/// its history). Sessions not found locally are restored from remote storage.
///
/// Returns `Ok(true)` when the user accepted a pending update. The caller
/// should print a message telling the user to relaunch `kigi`.
/// How the pager exited: whether the user accepted a pending update (the
/// caller relaunches `kigi`) and whether the terminal was still reading at
/// teardown (when not, further terminal output must be skipped).
pub struct RunExit {
pub quit_for_update: bool,
pub terminal_reading: bool,
}
pub async fn run(
args: PagerArgs,
bg_update_rx: Option<
tokio::sync::oneshot::Receiver<Option<kigi_update::auto_update::UpdateAvailable>>,
>,
) -> anyhow::Result<bool> {
) -> anyhow::Result<RunExit> {
kigi_tty_utils::redirect_native_stderr();
let screen_mode_override = screen_mode_relaunch::take_screen_mode_env_override();
let cancel = CancellationToken::new();
@@ -580,33 +582,58 @@ pub async fn run(
)
.await;
crate::unified_log::flush_blocking().await;
let _ = restore_terminal(terminal, writer_thread, screen_mode);
// After a timed-out writer join the terminal is not reading: exit hints would block on stderr.
let terminal_reading = !matches!(
restore_terminal(terminal, writer_thread, screen_mode),
Ok(WriterJoin::TimedOut)
);
cancel.cancel();
kigi_tty_utils::global_process_scope().kill_all();
match result {
Ok(run_result) => {
if run_result.quit_for_update {
return Ok(true);
return Ok(RunExit {
quit_for_update: true,
terminal_reading,
});
}
if let Some(relaunch) = run_result.relaunch.as_ref() {
if !terminal_reading {
// The replacement would inherit a silenced stderr and never show.
tracing::warn!("screen-mode relaunch skipped: the terminal stopped reading");
return Ok(RunExit {
quit_for_update: false,
terminal_reading,
});
}
if let Err(e) = screen_mode_relaunch::exec_screen_mode_relaunch(
&relaunch.session_id,
relaunch.minimal,
) {
tracing::error!(error = % e, "screen-mode relaunch failed");
print_relaunch_failure_hint(
&e,
&relaunch.session_id,
relaunch.minimal,
&mut io::stderr(),
);
if terminal_reading {
print_relaunch_failure_hint(
&e,
&relaunch.session_id,
relaunch.minimal,
&mut io::stderr(),
);
}
}
return Ok(false);
return Ok(RunExit {
quit_for_update: false,
terminal_reading,
});
}
if let Some(info) = run_result.exit_info {
if let Some(info) = run_result.exit_info
&& terminal_reading
{
print_exit_resume_hint(&info.session_id, info.minimal, &mut io::stderr());
}
Ok(false)
Ok(RunExit {
quit_for_update: false,
terminal_reading,
})
}
Err(e) => Err(e),
}
@@ -1065,15 +1092,23 @@ fn init_terminal(
kigi_crash_handler::disable_terminal_escape_restore();
})
}
/// Drop the terminal (closing the writer mpsc channel) and join the
/// writer thread. After this returns, subsequent direct stderr writes
/// are guaranteed to land strictly after every queued frame.
/// Drop the terminal and join the writer within `WRITER_JOIN_GRACE`; after `TimedOut` the writer may still hold the stderr lock, so callers must not take it unbounded.
fn drain_writer_thread_before_teardown(
terminal: PagerTerminal,
writer_thread: crate::render::draw::WriterThread,
) {
) -> WriterJoin {
drop(terminal);
writer_thread.join();
let join = writer_thread.join_within(crate::render::draw::WRITER_JOIN_GRACE);
if join == WriterJoin::TimedOut {
crate::unified_log::warn(
"term.writer.join_timeout",
None,
Some(serde_json::json!({
"grace_ms": crate::render::draw::WRITER_JOIN_GRACE.as_millis() as u64
})),
);
}
join
}
/// Inline teardown escape sequences in the canonical order, shared by
/// `restore_terminal` and `set_panic_hook` so the on-wire byte order is
@@ -1104,7 +1139,12 @@ fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option<
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::DisableFocusChange);
});
pop_gboom_keyboard_flags();
// Teardown runs after the writer is gone, so this pop is inline.
if GBOOM_KEYBOARD_PUSHED.swap(false, Ordering::AcqRel) {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
});
}
if crate::terminal::take_kitty_flags_pushed() {
kigi_shell::util::with_locked_stderr(|stderr| {
let _ = execute!(stderr, event::PopKeyboardEnhancementFlags);
@@ -1134,15 +1174,57 @@ fn emit_terminal_teardown_sequences(mode: ScreenMode, inline_cursor_row: Option<
#[cfg(windows)]
win_native_selection::restore_stdin_mode();
}
/// Consumes `terminal` and `writer_thread`: queues a final clear in
/// fullscreen mode, drains the writer thread, then emits the inline
/// teardown sequences. The drain ordering guarantees no late frame can
/// land after `LeaveAlternateScreen`.
/// Bound on teardown writes when the stderr lock may be wedged (panic hook; a timed-out writer join).
const TEARDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
/// Run a best-effort teardown on a helper thread for at most `grace`; on timeout it is detached (the process is exiting), inline if no thread can spawn.
fn run_bounded_teardown(f: impl FnOnce() + Send + 'static, grace: std::time::Duration) -> bool {
let slot = std::sync::Arc::new(parking_lot::Mutex::new(Some(f)));
let (done_tx, done_rx) = std::sync::mpsc::channel::<()>();
let worker_slot = std::sync::Arc::clone(&slot);
let spawned = std::thread::Builder::new()
.name("bounded-teardown".into())
.spawn(move || {
if let Some(f) = worker_slot.lock().take() {
f();
}
let _ = done_tx.send(());
});
match spawned {
Ok(_) => done_rx.recv_timeout(grace).is_ok(),
Err(_) => {
if let Some(f) = slot.lock().take() {
f();
}
true
}
}
}
/// Clear (fullscreen), drain the writer, then emit the teardown sequences; `TimedOut` means the terminal is not reading and further stderr writes may block.
fn restore_terminal(
terminal: PagerTerminal,
writer_thread: crate::render::draw::WriterThread,
mode: ScreenMode,
) -> io::Result<WriterJoin> {
let join = restore_terminal_with(
terminal,
writer_thread,
mode,
drain_writer_thread_before_teardown,
emit_terminal_teardown_sequences,
)?;
if join == WriterJoin::TimedOut {
// Later exit output (hints, relaunch notice, update result, error report) must not block on a stalled tty.
kigi_tty_utils::silence_native_stderr();
}
Ok(join)
}
fn restore_terminal_with(
mut terminal: PagerTerminal,
writer_thread: crate::render::draw::WriterThread,
mode: ScreenMode,
) -> io::Result<()> {
drain: impl FnOnce(PagerTerminal, crate::render::draw::WriterThread) -> WriterJoin,
teardown: impl FnOnce(ScreenMode, Option<u16>) + Send + 'static,
) -> io::Result<WriterJoin> {
if mode.is_fullscreen() {
let _ = terminal.clear();
{
@@ -1151,14 +1233,24 @@ fn restore_terminal(
}
}
let inline_cursor_row = (!mode.is_fullscreen()).then(|| terminal.viewport_area().bottom());
drain_writer_thread_before_teardown(terminal, writer_thread);
emit_terminal_teardown_sequences(mode, inline_cursor_row);
let join = drain(terminal, writer_thread);
// A clean join proves past writes landed, not that the terminal still reads: every teardown write is bounded.
let torn_down = run_bounded_teardown(move || teardown(mode, inline_cursor_row), TEARDOWN_GRACE);
if !torn_down {
tracing::warn!(
grace_ms = TEARDOWN_GRACE.as_millis() as u64,
"terminal teardown did not finish in time; detaching"
);
}
drain_pending_events_with_timeout(std::time::Duration::from_millis(10));
let _ = terminal::disable_raw_mode();
signal_handler::mark_restored();
kigi_crash_handler::disable_terminal_escape_restore();
kigi_tty_utils::restore_native_stderr();
Ok(())
if join == WriterJoin::TimedOut || !torn_down {
return Ok(WriterJoin::TimedOut);
}
Ok(WriterJoin::Joined)
}
pub(crate) fn set_terminal_title(title: &str) {
let full = terminal_title_string(title);
@@ -1183,18 +1275,88 @@ fn terminal_title_string(title: &str) -> String {
fn set_panic_hook(mode: ScreenMode) {
let hook = panic::take_hook();
panic::set_hook(Box::new(move |info| {
emit_terminal_teardown_sequences(mode, None);
// The writer thread may hold the stderr lock in a blocked tty write.
let torn_down = run_bounded_teardown(
move || emit_terminal_teardown_sequences(mode, None),
TEARDOWN_GRACE,
);
let _ = terminal::disable_raw_mode();
signal_handler::mark_restored();
kigi_crash_handler::disable_terminal_escape_restore();
kigi_tty_utils::restore_native_stderr();
kigi_tty_utils::global_process_scope().kill_all();
if !torn_down {
// The previous hook prints to stderr, which is not reading; keep the log report only.
kigi_tty_utils::silence_native_stderr();
tracing::error!(panic = %info, "process panicked while the terminal was stalled");
return;
}
hook(info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
fn test_terminal_and_writer_thread() -> (PagerTerminal, crate::render::draw::WriterThread) {
let (frame_tx, writer_sync, writer_thread) = crate::render::draw::spawn_writer_thread();
let backend =
CrosstermBackend::new(crate::render::draw::TermWriter::new(frame_tx, writer_sync));
let terminal = kigi_ratatui_inline::Terminal::with_options(
backend,
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(ratatui::layout::Rect::new(0, 0, 80, 10)),
},
)
.expect("fixed-viewport terminal");
(terminal, writer_thread)
}
/// After a timed-out writer join the teardown must be bounded: the writer may hold the stderr lock.
#[test]
fn restore_bounds_teardown_after_a_timed_out_writer_join() {
let (terminal, writer_thread) = test_terminal_and_writer_thread();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let started = std::time::Instant::now();
let result = restore_terminal_with(
terminal,
writer_thread,
ScreenMode::Inline,
|terminal, writer_thread| {
drop(terminal);
drop(writer_thread);
WriterJoin::TimedOut
},
move |_, _| {
let _ = release_rx.recv();
},
);
assert_eq!(result.expect("restore"), WriterJoin::TimedOut);
assert!(
started.elapsed() < TEARDOWN_GRACE + std::time::Duration::from_secs(5),
"teardown must be detached at the grace deadline"
);
let _ = release_tx.send(());
}
/// A clean join followed by a teardown that never returns still reports a stalled terminal.
#[test]
fn restore_reports_a_stalled_terminal_when_teardown_times_out_after_a_clean_join() {
let (terminal, writer_thread) = test_terminal_and_writer_thread();
let (release_tx, release_rx) = std::sync::mpsc::channel::<()>();
let result = restore_terminal_with(
terminal,
writer_thread,
ScreenMode::Inline,
|terminal, writer_thread| {
drop(terminal);
drop(writer_thread);
WriterJoin::Joined
},
move |_, _| {
let _ = release_rx.recv();
},
);
assert_eq!(result.expect("restore"), WriterJoin::TimedOut);
let _ = release_tx.send(());
}
/// `[ui].cursor_blink` tri-state → startup cursor policy; the `None`
/// default must be Inherit (emit nothing).
#[test]