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:
1 parent
91f14112bf
commit
4512df79e6
17 files changed
+560
-165
No files matched your search
@@ -42,10 +42,15 @@ pub struct NotificationService {
|
||||
/// `true` after the first notification; cleared via
|
||||
/// [`clear_permission_notification`] when the queue drains to empty.
|
||||
permission_notified: bool,
|
||||
/// Notification, title and progress escapes ride the frame queue (see `EscapeWriter`).
|
||||
escape_writer: crate::render::draw::EscapeWriter,
|
||||
}
|
||||
|
||||
impl NotificationService {
|
||||
pub fn new(config: NotificationConfig) -> Self {
|
||||
pub fn new(
|
||||
config: NotificationConfig,
|
||||
escape_writer: crate::render::draw::EscapeWriter,
|
||||
) -> Self {
|
||||
let terminal_ctx = crate::terminal::terminal_context();
|
||||
let protocol = resolve_protocol(config.method, terminal_ctx);
|
||||
let focus_tracker = focus::FocusTracker::new(
|
||||
@@ -64,6 +69,7 @@ impl NotificationService {
|
||||
progress_active: false,
|
||||
progress_last_sent: None,
|
||||
permission_notified: false,
|
||||
escape_writer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +117,7 @@ impl NotificationService {
|
||||
&event.title,
|
||||
&event.body,
|
||||
self.terminal_ctx,
|
||||
&self.escape_writer,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -132,13 +139,7 @@ impl NotificationService {
|
||||
self.clear_progress_into(&mut buf);
|
||||
}
|
||||
|
||||
if !buf.is_empty() {
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
use std::io::Write;
|
||||
let _ = stderr.write_all(buf.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
}
|
||||
self.escape_writer.emit(buf);
|
||||
}
|
||||
|
||||
/// Build escape sequences to set the title and progress bar to idle
|
||||
@@ -206,22 +207,9 @@ impl NotificationService {
|
||||
pub fn shutdown(&mut self) {
|
||||
// Reset the tab title back to "kigi" so it doesn't linger on the
|
||||
// last activity label after exit.
|
||||
let title_esc = self.title_manager.reset();
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
use std::io::Write as _;
|
||||
let _ = stderr.write_all(title_esc.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
|
||||
let mut buf = String::new();
|
||||
let mut buf = self.title_manager.reset();
|
||||
self.clear_progress_into(&mut buf);
|
||||
if !buf.is_empty() {
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
use std::io::Write as _;
|
||||
let _ = stderr.write_all(buf.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
}
|
||||
self.escape_writer.emit(buf);
|
||||
}
|
||||
|
||||
/// Returns `true` if a terminal notification for `ApprovalRequired` has
|
||||
@@ -281,6 +269,7 @@ impl NotificationService {
|
||||
progress_active: false,
|
||||
progress_last_sent: None,
|
||||
permission_notified: false,
|
||||
escape_writer: crate::render::draw::EscapeWriter::disconnected(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,6 +650,19 @@ mod tests {
|
||||
assert!(!svc.is_progress_active());
|
||||
}
|
||||
|
||||
/// Shutdown escapes ride the writer queue, never an inline stderr write.
|
||||
#[test]
|
||||
fn shutdown_queues_the_title_reset_on_the_writer() {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
let mut svc = NotificationService::new(
|
||||
NotificationConfig::default(),
|
||||
crate::render::draw::EscapeWriter::new(tx, crate::render::draw::WriterSync::new()),
|
||||
);
|
||||
svc.shutdown();
|
||||
let payload = rx.try_recv().expect("shutdown must queue its escapes");
|
||||
assert!(payload.starts_with(b"\x1b]"), "{payload:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shutdown_clears_active_progress() {
|
||||
let mut svc = NotificationService::new_for_test(NotificationConfig {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use std::borrow::Cow;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::notifications::tmux;
|
||||
use crate::terminal::{MultiplexerKind, TerminalContext, TerminalName};
|
||||
@@ -68,6 +67,7 @@ pub fn emit_notification(
|
||||
title: &str,
|
||||
body: &str,
|
||||
ctx: &TerminalContext,
|
||||
writer: &crate::render::draw::EscapeWriter,
|
||||
) {
|
||||
// For body-only protocols (OSC 9, OSC 99), fold the title (session
|
||||
// name) into the body so it's visible. For OSC 777 (Ghostty), the
|
||||
@@ -81,23 +81,13 @@ pub fn emit_notification(
|
||||
NotificationProtocol::None => return,
|
||||
};
|
||||
|
||||
// Notifications fire from the event-loop thread: queued, never an inline stderr write.
|
||||
if ctx.is_tmux_backed() {
|
||||
let wrapped = tmux::tmux_passthrough(&sequence);
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let _ = stderr.write_all(wrapped.as_bytes());
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
writer.emit(tmux::tmux_passthrough(&sequence));
|
||||
} else if matches!(protocol, NotificationProtocol::Bel) {
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let _ = stderr.write_all(BEL_BYTE);
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
writer.emit(BEL_BYTE);
|
||||
} else {
|
||||
let bytes = sequence.as_bytes();
|
||||
kigi_shell::util::with_locked_stderr(|stderr| {
|
||||
let _ = stderr.write_all(bytes);
|
||||
let _ = stderr.flush();
|
||||
});
|
||||
writer.emit(sequence.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,31 +282,61 @@ mod tests {
|
||||
#[test]
|
||||
fn emit_none_is_noop() {
|
||||
let ctx = ctx_with_brand(TerminalName::KigiDesktop);
|
||||
emit_notification(NotificationProtocol::None, "title", "body", &ctx);
|
||||
emit_notification(
|
||||
NotificationProtocol::None,
|
||||
"title",
|
||||
"body",
|
||||
&ctx,
|
||||
&crate::render::draw::EscapeWriter::disconnected(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_bel_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Unknown);
|
||||
emit_notification(NotificationProtocol::Bel, "", "", &ctx);
|
||||
emit_notification(
|
||||
NotificationProtocol::Bel,
|
||||
"",
|
||||
"",
|
||||
&ctx,
|
||||
&crate::render::draw::EscapeWriter::disconnected(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_osc9_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Iterm2);
|
||||
emit_notification(NotificationProtocol::Osc9, "title", "body", &ctx);
|
||||
emit_notification(
|
||||
NotificationProtocol::Osc9,
|
||||
"title",
|
||||
"body",
|
||||
&ctx,
|
||||
&crate::render::draw::EscapeWriter::disconnected(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_osc99_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Kitty);
|
||||
emit_notification(NotificationProtocol::Osc99, "title", "body", &ctx);
|
||||
emit_notification(
|
||||
NotificationProtocol::Osc99,
|
||||
"title",
|
||||
"body",
|
||||
&ctx,
|
||||
&crate::render::draw::EscapeWriter::disconnected(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emit_osc777_does_not_panic() {
|
||||
let ctx = ctx_with_brand(TerminalName::Ghostty);
|
||||
emit_notification(NotificationProtocol::Osc777, "title", "body", &ctx);
|
||||
emit_notification(
|
||||
NotificationProtocol::Osc777,
|
||||
"title",
|
||||
"body",
|
||||
&ctx,
|
||||
&crate::render::draw::EscapeWriter::disconnected(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user