Skip to main content

katui_rt/
tty.rs

1//! The controlling-terminal transport: the probe's read and write side,
2//! in raw mode for the exchange and restored after it.
3
4use std::fs::{File, OpenOptions};
5use std::os::fd::AsFd;
6use std::time::{Duration, Instant};
7
8use katui_proto::kitty::IdSpace;
9use katui_proto::probe::CellPixels;
10use katui_proto::probe::{Capabilities, Env};
11use katui_proto::probe::{Transport, probe, sniff};
12use rustix::event::{PollFd, PollFlags, Timespec, poll};
13use rustix::fd::OwnedFd;
14use rustix::fs::{Mode, OFlags};
15use rustix::termios::{
16    InputModes, LocalModes, OptionalActions, QueueSelector, SpecialCodeIndex, Termios, Winsize,
17    tcflush, tcgetattr, tcgetwinsize, tcsetattr,
18};
19
20use crate::RtError;
21
22/// Open the controlling terminal for the read/write exchange,
23/// non-blocking: a poll that reports readable without deliverable bytes
24/// (macOS ptys do this right after the process's own write) must degrade
25/// to a retry, never to a blocked read.
26fn open_tty() -> Result<File, RtError> {
27    let flags = OFlags::RDWR | OFlags::NONBLOCK;
28    let fd: OwnedFd = rustix::fs::open("/dev/tty", flags, Mode::empty())?;
29    Ok(File::from(fd))
30}
31
32/// The terminal in raw mode until drop: no echo, no line buffering, no
33/// flow control, a one-byte read floor. `with_signals` keeps ISIG on for
34/// the probe (Ctrl-C must stay a signal there) and drops it for raw
35/// input, where Ctrl-C arrives as a byte.
36struct RawTty {
37    tty: File,
38    saved: Termios,
39}
40
41impl RawTty {
42    fn enter(tty: File, with_signals: bool) -> Result<Self, RtError> {
43        let saved = tcgetattr(tty.as_fd())?;
44        let mut raw = saved.clone();
45        let mut modes = LocalModes::ECHO | LocalModes::ICANON;
46        if !with_signals {
47            modes |= LocalModes::ISIG;
48        }
49        raw.local_modes -= modes;
50        raw.input_modes -= InputModes::IXON | InputModes::ICRNL;
51        // pin the noncanonical read floor so a previous owner's VMIN cannot
52        // stall trailing fragments
53        raw.special_codes[SpecialCodeIndex::VMIN] = 1;
54        raw.special_codes[SpecialCodeIndex::VTIME] = 0;
55        tcsetattr(tty.as_fd(), OptionalActions::Now, &raw)?;
56        Ok(Self { tty, saved })
57    }
58}
59
60impl Drop for RawTty {
61    fn drop(&mut self) {
62        // replies and keys that arrived after the last read would reach
63        // the parent shell as typed junk, so we flush them first; drop
64        // cannot report, so the restore is best effort
65        let _ = tcflush(self.tty.as_fd(), QueueSelector::IFlush);
66        let _ = tcsetattr(self.tty.as_fd(), OptionalActions::Now, &self.saved);
67    }
68}
69
70/// The pause after the terminal claims input it cannot deliver.
71const SPURIOUS_READY_BACKOFF: Duration = Duration::from_millis(4);
72
73/// Raw keyboard input for the duration of the guard: the controlling
74/// terminal delivers bytes one at a time, with `Ctrl-C`, `q` and `Esc`
75/// arriving as plain bytes instead of signals, and the saved state
76/// restores on drop whatever ends the run.
77pub struct RawInput(RawTty);
78
79impl RawInput {
80    /// Grab the controlling terminal in raw mode.
81    pub fn open() -> Result<Self, RtError> {
82        Ok(Self(RawTty::enter(open_tty()?, false)?))
83    }
84
85    /// Wait up to `wait` for input; `None` on silence.
86    pub fn read_chunk(&mut self, wait: Duration) -> Option<Vec<u8>> {
87        let deadline = Instant::now() + wait;
88        let mut buffer = [0u8; 4096];
89        loop {
90            let remaining = deadline.saturating_duration_since(Instant::now());
91            let mut fds = [PollFd::new(&self.0.tty, PollFlags::IN)];
92            if !matches!(poll(&mut fds, Some(&timespec(remaining))), Ok(ready) if ready > 0) {
93                return None;
94            }
95            match rustix::io::read(self.0.tty.as_fd(), &mut buffer) {
96                Ok(count) if count > 0 => return buffer.get(..count).map(<[u8]>::to_vec),
97                // macOS ptys can report readable with no bytes to deliver,
98                // so we back off here or this loop would spin a whole core
99                Err(rustix::io::Errno::AGAIN) if !remaining.is_zero() => {
100                    std::thread::sleep(SPURIOUS_READY_BACKOFF);
101                }
102                _ => return None,
103            }
104        }
105    }
106}
107
108/// The probe transport over `/dev/tty`, raw for its lifetime and restored
109/// on drop however the exchange ends. The total listening budget lives
110/// here: reads stop returning once it is spent.
111pub struct TtyTransport {
112    raw: RawTty,
113    deadline: Instant,
114}
115
116impl TtyTransport {
117    /// Open the controlling terminal and switch it to raw mode. `budget`
118    /// bounds the whole exchange, writes included.
119    pub fn open(budget: Duration) -> Result<Self, RtError> {
120        let tty = open_tty()?;
121        Self::from_tty(tty, budget)
122    }
123
124    /// The raw-mode lifecycle over an already-open terminal file: the
125    /// seam the pty test drives, since /dev/tty cannot be pointed
126    /// elsewhere.
127    fn from_tty(tty: File, budget: Duration) -> Result<Self, RtError> {
128        Ok(Self {
129            raw: RawTty::enter(tty, true)?,
130            // a budget overflowing the clock means a limit nobody reaches
131            deadline: Instant::now()
132                .checked_add(budget)
133                .unwrap_or_else(|| Instant::now() + Duration::from_secs(86_400 * 365)),
134        })
135    }
136}
137
138fn window_size() -> Result<Winsize, RtError> {
139    let tty = OpenOptions::new().read(true).write(true).open("/dev/tty")?;
140    Ok(tcgetwinsize(tty.as_fd())?)
141}
142
143/// The terminal's size in cells.
144pub fn size() -> Result<katui_scene::CellSize, RtError> {
145    let winsize = window_size()?;
146    Ok(katui_scene::CellSize {
147        columns: winsize.ws_col,
148        rows: winsize.ws_row,
149    })
150}
151
152/// The probe every demo and binary runs over the controlling terminal,
153/// falling back to the environment sniff when no terminal answers.
154#[must_use]
155pub fn probe_default() -> Capabilities {
156    let mut caps = probe_terminal();
157    if caps.multiplexed() {
158        caps.id_space = IdSpace::of_process(std::process::id());
159    }
160    caps
161}
162
163fn probe_terminal() -> Capabilities {
164    let env = Env::capture();
165    let caps = probe_once(&env, Duration::from_millis(600));
166    if worth_retrying(&env, &caps) {
167        let retried = probe_once(&env, Duration::from_millis(1200));
168        if retried.kitty {
169            return retried;
170        }
171    }
172    caps
173}
174
175/// A multiplexer inside kitty sometimes relays kitty's answer too late for
176/// the first budget, and losing it drops a kitty terminal to blocks, so we
177/// ask once more with a longer one.
178fn worth_retrying(env: &Env, caps: &Capabilities) -> bool {
179    env.inside_multiplexer()
180        && env.launched_from_kitty()
181        && env.forced_graphics().is_none()
182        && !caps.kitty
183}
184
185fn probe_once(env: &Env, budget: Duration) -> Capabilities {
186    match TtyTransport::open(budget) {
187        Ok(mut transport) => {
188            let caps = probe(&mut transport, env);
189            // answers racing the budget boundary would otherwise leak
190            // into the parent shell as typed junk; spend a quiet window
191            // draining whatever still arrives, and drop's input flush
192            // then clears the rest
193            let until = Instant::now() + Duration::from_millis(200);
194            while Instant::now() < until {
195                let _ = transport.read_timeout(Duration::from_millis(50));
196            }
197            caps
198        }
199        Err(_) => sniff(env),
200    }
201}
202
203/// The cell size the terminal driver reports, when it reports pixels.
204#[must_use]
205fn measured_cell() -> Option<CellPixels> {
206    let window = window_size().ok()?;
207    let area = CellPixels {
208        width: window.ws_xpixel,
209        height: window.ws_ypixel,
210    };
211    let cells = katui_scene::CellSize {
212        columns: window.ws_col,
213        rows: window.ws_row,
214    };
215    cell_from_area(area, cells)
216}
217
218/// The pixel size of one cell: what the terminal driver reports now,
219/// else what the probe answered, else the probed text area over the cell
220/// count, else a typical cell. The driver comes first because it follows
221/// a font zoom, which changes the cell after the probe. A multiplexer
222/// leaves all of them unanswered at times, and a placement's cell span
223/// scales whatever we choose, so the typical cell is a safe guess.
224#[must_use]
225pub fn cell_size(caps: &Capabilities) -> CellPixels {
226    pick_cell(measured_cell(), caps.cell, caps.text_area.zip(size().ok()))
227}
228
229fn pick_cell(
230    measured: Option<CellPixels>,
231    probed: Option<CellPixels>,
232    area: Option<(CellPixels, katui_scene::CellSize)>,
233) -> CellPixels {
234    measured
235        .or(probed)
236        .or_else(|| area.and_then(|(area, cells)| cell_from_area(area, cells)))
237        .unwrap_or(CellPixels {
238            width: 10,
239            height: 20,
240        })
241}
242
243fn cell_from_area(area: CellPixels, cells: katui_scene::CellSize) -> Option<CellPixels> {
244    let width = area.width.checked_div(cells.columns)?;
245    let height = area.height.checked_div(cells.rows)?;
246    (width > 0 && height > 0).then_some(CellPixels { width, height })
247}
248
249fn timespec(wait: Duration) -> Timespec {
250    Timespec {
251        tv_sec: wait.as_secs().min(i64::MAX.unsigned_abs()).cast_signed(),
252        tv_nsec: i64::from(wait.subsec_nanos()),
253    }
254}
255
256impl Transport for TtyTransport {
257    fn write(&mut self, bytes: &[u8]) -> std::io::Result<()> {
258        let mut written = 0;
259        while let Some(rest) = bytes.get(written..) {
260            if rest.is_empty() {
261                break;
262            }
263            match rustix::io::write(self.raw.tty.as_fd(), rest) {
264                Ok(0) => break,
265                Ok(n) => written += n,
266                Err(rustix::io::Errno::INTR) => {}
267                // the fd is non-blocking and a pty buffer holds about a
268                // kilobyte, so we wait on writability: a fixed sleep here
269                // caps the channel at a few hundred KB/s
270                Err(rustix::io::Errno::AGAIN) => {
271                    let mut fds = [PollFd::new(&self.raw.tty, PollFlags::OUT)];
272                    match poll(&mut fds, Some(&timespec(Duration::from_millis(100)))) {
273                        Ok(_) | Err(rustix::io::Errno::INTR) => {}
274                        Err(errno) => return Err(std::io::Error::from(errno)),
275                    }
276                }
277                Err(errno) => return Err(std::io::Error::from(errno)),
278            }
279        }
280        Ok(())
281    }
282
283    fn read_timeout(&mut self, wait: Duration) -> Option<Vec<u8>> {
284        let start = Instant::now();
285        if start >= self.deadline {
286            return None;
287        }
288        let end = (start + wait).min(self.deadline);
289        while Instant::now() < end {
290            let timeout = timespec(end - Instant::now());
291            let mut fds = [PollFd::new(&self.raw.tty, PollFlags::IN)];
292            // a signal must not end the exchange, only the budget may
293            let ready = loop {
294                match poll(&mut fds, Some(&timeout)) {
295                    Ok(ready) => break ready,
296                    Err(rustix::io::Errno::INTR) => {}
297                    Err(_) => return None,
298                }
299            };
300            if ready == 0 {
301                return None;
302            }
303            let mut buffer = [0u8; 4096];
304            match rustix::io::read(self.raw.tty.as_fd(), &mut buffer) {
305                Ok(0) => return None,
306                Ok(n) => return buffer.get(..n).map(<[u8]>::to_vec),
307                // the poll reported readable but no byte arrived; the
308                // fd is non-blocking, so spend the rest of the wait
309                // budget polling again instead of reporting silence
310                Err(rustix::io::Errno::AGAIN) => {}
311                Err(_) => return None,
312            }
313        }
314        None
315    }
316}
317
318#[cfg(test)]
319mod area_tests {
320    use katui_proto::probe::CellPixels;
321    use katui_scene::CellSize;
322
323    #[test]
324    fn a_text_area_over_its_cells_gives_the_cell() {
325        let area = CellPixels {
326            width: 560,
327            height: 700,
328        };
329        let cells = CellSize {
330            columns: 80,
331            rows: 50,
332        };
333        assert_eq!(
334            super::cell_from_area(area, cells),
335            Some(CellPixels {
336                width: 7,
337                height: 14
338            })
339        );
340        let empty = CellSize {
341            columns: 0,
342            rows: 0,
343        };
344        assert_eq!(super::cell_from_area(area, empty), None);
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use std::time::{Duration, Instant};
351
352    use super::{LocalModes, OpenOptions, TtyTransport, tcgetattr};
353    use katui_proto::probe::CellPixels;
354    use std::os::fd::AsFd as _;
355
356    /// A pty pair for the terminal tests: the master keeps the device
357    /// alive, the slave gets driven. `None` where the sandbox denies
358    /// device nodes.
359    #[allow(clippy::print_stderr)] // the skip notice belongs on stderr
360    fn pty_slave() -> Option<(rustix::fd::OwnedFd, std::fs::File)> {
361        use rustix::pty::{OpenptFlags, openpt, ptsname, unlockpt};
362
363        let master = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY)
364            .expect("opening a pty pair cannot fail");
365        unlockpt(&master).expect("unlocking the pty cannot fail");
366        let name = [0u8; 64];
367        let name = ptsname(&master, name).expect("the pty has a name");
368        let path = name.to_string_lossy().into_owned();
369        if let Ok(slave) = OpenOptions::new().read(true).write(true).open(&path) {
370            Some((master, slave))
371        } else {
372            eprintln!("skipping pty test: no permission to open {path}");
373            None
374        }
375    }
376
377    #[test]
378    fn opening_the_controlling_terminal_never_panics() {
379        // a controlling terminal in the test environment is fine too: the
380        // point is that open never panics, and both arms restore state
381        if TtyTransport::open(Duration::from_millis(1)).is_ok() {}
382    }
383
384    /// The raw-mode lifecycle, verified against a real pty: attributes
385    /// flip while the transport lives and return exactly on drop.
386    #[test]
387    fn raw_mode_flips_and_restores_on_a_pty() {
388        let Some((_master, slave)) = pty_slave() else {
389            return;
390        };
391        let witness = slave.try_clone().expect("duplicating the fd cannot fail");
392        let before = tcgetattr(witness.as_fd()).expect("the pty answers tcgetattr");
393
394        let transport = TtyTransport::from_tty(slave, Duration::from_millis(50))
395            .expect("a pty enters raw mode");
396        let during = tcgetattr(witness.as_fd()).expect("the pty answers tcgetattr");
397        assert!(
398            !during
399                .local_modes
400                .contains(LocalModes::ICANON | LocalModes::ECHO),
401            "raw mode clears line buffering and echo"
402        );
403        drop(transport);
404
405        let after = tcgetattr(witness.as_fd()).expect("the pty answers tcgetattr");
406        assert_eq!(
407            before.local_modes, after.local_modes,
408            "drop restores the saved local modes"
409        );
410        assert_eq!(
411            before.input_modes, after.input_modes,
412            "drop restores the saved input modes"
413        );
414    }
415
416    /// A silent terminal must end the exchange at the budget, never in a
417    /// blocked read: macOS ptys report readable right after the probe's
418    /// own write, so the fd is non-blocking and a lying poll retries.
419    #[test]
420    fn the_probe_budget_ends_a_silent_exchange() {
421        use katui_proto::probe::ProbeSource;
422        use katui_proto::probe::{Env, probe};
423
424        let Some((_master, slave)) = pty_slave() else {
425            return;
426        };
427
428        let env = Env {
429            term: Some("xterm-256color".to_owned()),
430            ..Env::default()
431        };
432        let mut transport = TtyTransport::from_tty(slave, Duration::from_millis(300))
433            .expect("a pty enters raw mode");
434        let start = Instant::now();
435        let caps = probe(&mut transport, &env);
436        assert_eq!(
437            caps.trust,
438            ProbeSource::Sniffed,
439            "a silent pty proves nothing"
440        );
441        assert!(
442            start.elapsed() < Duration::from_secs(2),
443            "the budget ends the exchange, not a blocked read"
444        );
445    }
446
447    #[test]
448    fn the_driver_cell_wins_then_the_probe_then_the_area() {
449        let cell = |width, height| CellPixels { width, height };
450        let cells = katui_scene::CellSize {
451            columns: 10,
452            rows: 10,
453        };
454        let area = Some((cell(90, 180), cells));
455        assert_eq!(
456            super::pick_cell(Some(cell(8, 16)), Some(cell(7, 14)), area),
457            cell(8, 16)
458        );
459        assert_eq!(super::pick_cell(None, Some(cell(7, 14)), area), cell(7, 14));
460        assert_eq!(super::pick_cell(None, None, area), cell(9, 18));
461        assert_eq!(super::pick_cell(None, None, None), cell(10, 20));
462    }
463
464    #[test]
465    fn only_a_kitty_launched_multiplexer_without_graphics_retries() {
466        use katui_proto::probe::{Capabilities, Env};
467
468        let tmux_in_kitty = Env {
469            tmux: Some("pane".to_owned()),
470            kitty_window_id: Some("1".to_owned()),
471            ..Env::default()
472        };
473        let lost = Capabilities::default();
474        assert!(super::worth_retrying(&tmux_in_kitty, &lost));
475        let found = Capabilities {
476            kitty: true,
477            ..Capabilities::default()
478        };
479        assert!(!super::worth_retrying(&tmux_in_kitty, &found));
480        let forced = Env {
481            graphics: Some("blocks".to_owned()),
482            ..tmux_in_kitty.clone()
483        };
484        assert!(!super::worth_retrying(&forced, &lost));
485        let bare_kitty = Env {
486            tmux: None,
487            ..tmux_in_kitty
488        };
489        assert!(!super::worth_retrying(&bare_kitty, &lost));
490    }
491}