1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
//! Terminal input reader. Split out of `ui/mod.rs::run_interactive`
//! (dirge-4y4l stage 12a): a dedicated OS thread that polls crossterm for
//! key/mouse/paste/resize events and forwards them to the UI loop as
//! [`UserEvent`]s over an mpsc channel. Kept off the async runtime because
//! `event::read()` is blocking; cooperative shutdown via the terminal
//! module's `EVENT_READER_SHUTDOWN` / `EVENT_READER_EXITED` flags.
use crossterm::event;
use crossterm::event::{MouseButton, MouseEventKind};
use crate::event::UserEvent;
/// Spawn the blocking crossterm reader thread. `user_tx` is consumed (pass
/// a clone — the caller keeps its own sender for other event sources). The
/// `JoinHandle` is stored in `READER_HANDLE` so the sandbox attach path
/// can fully join the thread before draining stdin.
pub(crate) fn spawn_input_reader(user_tx: tokio::sync::mpsc::UnboundedSender<UserEvent>) {
let handle = std::thread::spawn(move || {
// ── CFS priority boost for the input reader ──────────────
// nice -20 gives ~5900x scheduling weight over KVM (nice 19)
// threads. Works without CAP_SYS_NICE on kernels with
// default RLIMIT_NICE (allows 0 to -20 for unprivileged).
#[cfg(unix)]
unsafe {
libc::setpriority(libc::PRIO_PROCESS, 0, -20);
}
// Poll-based loop so `TerminalGuard::drop` can signal a
// cooperative shutdown via `EVENT_READER_SHUTDOWN`. Previously
// this thread blocked in `event::read()` indefinitely; on
// teardown the guard's drain pass and this `read()` both held
// crossterm's internal mutex, racing for terminal-response
// bytes (OSC 11, primary DA, CPR). With the flag + 50ms
// poll-tick, the reader exits within ~50ms of the guard
// signalling, the mutex is released, and the drain runs
// uncontended.
loop {
if crate::ui::terminal::EVENT_READER_SHUTDOWN.load(std::sync::atomic::Ordering::Relaxed)
{
break;
}
match event::poll(std::time::Duration::from_millis(1)) {
Ok(true) => {}
Ok(false) => continue,
Err(_) => break,
}
// Re-check the shutdown flag between poll and read.
// poll() returning true means there are bytes on fd 0;
// if shutdown was signalled during poll, we must not
// consume those bytes — they belong to the drain pass.
if crate::ui::terminal::EVENT_READER_SHUTDOWN.load(std::sync::atomic::Ordering::Relaxed)
{
break;
}
// `clippy::collapsible_match` suggests moving the `is_err()` check into
// a match guard, but doing so tries to move bound values (e.g. `text`
// in `Event::Paste(text)`) inside the guard, which is rejected with
// E0507. Keep the nested `if`s.
#[allow(clippy::collapsible_match)]
match event::read() {
Ok(event::Event::Key(key)) => {
// Filter Release / Repeat events. Modern terminals
// (kitty keyboard protocol, Windows 10+ ConPTY,
// some iTerm2 modes) emit BOTH Press and Release
// for every keystroke — without this filter every
// typed char inserts twice ("ssuubb..." bug).
if key.kind != event::KeyEventKind::Press {
continue;
}
// With unbounded channel, sends never block — the only
// failure is a closed channel (UI loop exited).
if let Err(tokio::sync::mpsc::error::SendError(_)) =
user_tx.send(UserEvent::Key(key))
{
break;
}
}
Ok(event::Event::Mouse(m)) => {
// Wheel → scroll the output pane. Left button
// down/drag/up → app-level text selection
// (`ui::selection::handle`). Other buttons are
// ignored. Right/middle clicks fall through with
// no app action and the terminal's own handling
// for them takes over (paste, menu, etc.).
let ev = match m.kind {
MouseEventKind::ScrollUp => Some(UserEvent::ScrollUp {
row: m.row,
col: m.column,
}),
MouseEventKind::ScrollDown => Some(UserEvent::ScrollDown {
row: m.row,
col: m.column,
}),
MouseEventKind::Down(MouseButton::Left) => Some(UserEvent::MouseDown {
row: m.row,
col: m.column,
}),
MouseEventKind::Drag(MouseButton::Left) => Some(UserEvent::MouseDrag {
row: m.row,
col: m.column,
}),
MouseEventKind::Up(MouseButton::Left) => Some(UserEvent::MouseUp {
row: m.row,
col: m.column,
}),
_ => None,
};
if let Some(ev) = ev {
if let Err(tokio::sync::mpsc::error::SendError(_)) = user_tx.send(ev) {
break;
}
}
}
Ok(event::Event::Paste(text)) => {
if let Err(tokio::sync::mpsc::error::SendError(_)) =
user_tx.send(UserEvent::Paste(text))
{
break;
}
}
Ok(event::Event::Resize(_, _)) => {
if let Err(tokio::sync::mpsc::error::SendError(_)) =
user_tx.send(UserEvent::Resize)
{
break;
}
}
Err(_) => break,
_ => {}
}
}
// Tell `TerminalGuard::drop` we've actually exited so it can
// proceed past the wait barrier without sleeping on a
// timeout. Release-store paired with the guard's
// Acquire-load gives a clean happens-before relationship —
// by the time the guard observes `true`, every byte this
// thread consumed from crossterm's internal buffer is
// visible to subsequent reads.
crate::ui::terminal::EVENT_READER_EXITED.store(true, std::sync::atomic::Ordering::Release);
});
// Store the handle so `join_reader` can wait for the thread to
// actually exit — critical for the sandbox attach path where we
// need to guarantee the reader is gone before draining stdin.
if let Ok(mut guard) = crate::ui::terminal::READER_HANDLE.lock() {
*guard = Some(handle);
}
}