nextest_runner/input.rs
1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Input handling for nextest.
5//!
6//! Similar to signal handling, input handling is read by the runner and used to control
7//! non-signal-related aspects of the test run. For example, "i" to print information about which
8//! tests are currently running.
9
10use crate::errors::DisplayErrorChain;
11use crossterm::event::{Event, EventStream, KeyCode};
12use futures::StreamExt;
13use std::sync::{Arc, Mutex};
14use thiserror::Error;
15use tracing::{debug, warn};
16
17/// The kind of input handling to set up for a test run.
18///
19/// An `InputHandlerKind` can be passed into
20/// [`TestRunnerBuilder::build`](crate::runner::TestRunnerBuilder::build).
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum InputHandlerKind {
23 /// The standard input handler, which reads from standard input.
24 Standard,
25
26 /// A no-op input handler. Useful for tests.
27 Noop,
28}
29
30impl InputHandlerKind {
31 pub(crate) fn build(self) -> InputHandler {
32 match self {
33 Self::Standard => InputHandler::new(),
34 Self::Noop => InputHandler::noop(),
35 }
36 }
37}
38
39/// The input handler implementation.
40#[derive(Debug)]
41pub(crate) struct InputHandler {
42 // A scope guard that ensures non-canonical mode is disabled when this is
43 // dropped, along with a stream to read events from.
44 imp: Option<(InputHandlerImpl, EventStream)>,
45}
46
47impl InputHandler {
48 const INFO_CHAR: char = 't';
49
50 /// Creates a new `InputHandler` that reads from standard input.
51 pub(crate) fn new() -> Self {
52 if imp::is_foreground_process() {
53 // Try enabling non-canonical mode.
54 match InputHandlerImpl::new() {
55 Ok(handler) => {
56 let stream = EventStream::new();
57 debug!("enabled terminal non-canonical mode, reading input events");
58 Self {
59 imp: Some((handler, stream)),
60 }
61 }
62 Err(error) => {
63 warn!(
64 "failed to enable terminal non-canonical mode, \
65 cannot read input events: {}",
66 error,
67 );
68 Self::noop()
69 }
70 }
71 } else {
72 debug!(
73 "not reading input because nextest is not \
74 a foreground process in a terminal"
75 );
76 Self::noop()
77 }
78 }
79
80 /// Creates a new `InputHandler` that does nothing.
81 pub(crate) fn noop() -> Self {
82 Self { imp: None }
83 }
84
85 pub(crate) fn status(&self) -> InputHandlerStatus {
86 if self.imp.is_some() {
87 InputHandlerStatus::Enabled {
88 info_char: Self::INFO_CHAR,
89 }
90 } else {
91 InputHandlerStatus::Disabled
92 }
93 }
94
95 /// Receives an event from the input, or None if the input is closed and there are no more
96 /// events.
97 ///
98 /// This is a cancel-safe operation.
99 pub(crate) async fn recv(&mut self) -> Option<InputEvent> {
100 let (_, stream) = self.imp.as_mut()?;
101 loop {
102 let next = stream.next().await?;
103 // Everything after here must be cancel-safe: ideally no await
104 // points at all, but okay with discarding `next` if there are any
105 // await points.
106 match next {
107 Ok(Event::Key(key)) => {
108 if key.code == KeyCode::Char(Self::INFO_CHAR) && key.modifiers.is_empty() {
109 return Some(InputEvent::Info);
110 }
111 if key.code == KeyCode::Enter {
112 return Some(InputEvent::Enter);
113 }
114 }
115 Ok(event) => {
116 debug!("unhandled event: {:?}", event);
117 }
118 Err(error) => {
119 warn!("failed to read input event: {}", error);
120 }
121 }
122 }
123 }
124
125 /// Suspends the input handler temporarily, restoring the original terminal
126 /// state.
127 ///
128 /// Used by the stop signal handler.
129 #[cfg(unix)]
130 pub(crate) fn suspend(&mut self) {
131 let Some((handler, _)) = self.imp.as_mut() else {
132 return;
133 };
134
135 if let Err(error) = handler.restore() {
136 warn!("failed to suspend terminal non-canonical mode: {}", error);
137 // Don't set imp to None -- we want to try to reinit() on resume.
138 }
139 }
140
141 /// Resumes the input handler after a suspension.
142 ///
143 /// Used by the continue signal handler.
144 #[cfg(unix)]
145 pub(crate) fn resume(&mut self) {
146 let Some((handler, _)) = self.imp.as_mut() else {
147 // None means that the input handler is disabled, so there is
148 // nothing to resume.
149 return;
150 };
151
152 if let Err(error) = handler.reinit() {
153 warn!(
154 "failed to resume terminal non-canonical mode, \
155 cannot read input events: {}",
156 error
157 );
158 // Do set self.imp to None in this case -- we want to indicate to
159 // callers (e.g. via status()) that the input handler is disabled.
160 self.imp = None;
161 }
162 }
163}
164
165/// The status of the input handler, returned by
166/// [`TestRunner::input_handler_status`](crate::runner::TestRunner::input_handler_status).
167pub enum InputHandlerStatus {
168 /// The input handler is enabled.
169 Enabled {
170 /// The character that triggers the "info" event.
171 info_char: char,
172 },
173
174 /// The input handler is disabled.
175 Disabled,
176}
177
178#[derive(Clone, Debug)]
179struct InputHandlerImpl {
180 // `Arc<Mutex<_>>` for coordination between the drop handler and the panic
181 // hook.
182 guard: Arc<Mutex<imp::InputGuard>>,
183}
184
185impl InputHandlerImpl {
186 fn new() -> Result<Self, InputHandlerCreateError> {
187 let guard = imp::InputGuard::new().map_err(InputHandlerCreateError::EnableNonCanonical)?;
188
189 // At this point, the new terminal state is committed. Install a
190 // panic hook to restore the original state.
191 let ret = Self {
192 guard: Arc::new(Mutex::new(guard)),
193 };
194
195 let ret2 = ret.clone();
196 let panic_hook = std::panic::take_hook();
197 std::panic::set_hook(Box::new(move |info| {
198 // Ignore errors to avoid double-panicking.
199 if let Err(error) = ret2.restore() {
200 eprintln!(
201 "failed to restore terminal state: {}",
202 DisplayErrorChain::new(error)
203 );
204 }
205 panic_hook(info);
206 }));
207
208 Ok(ret)
209 }
210
211 #[cfg(unix)]
212 fn reinit(&self) -> Result<(), InputHandlerCreateError> {
213 // Make a new input guard and replace the old one. Don't set a new panic
214 // hook.
215 //
216 // The mutex is shared by the panic hook and self/the drop handler, so
217 // the change below will also be visible to the panic hook. But we
218 // acquire the mutex first to avoid a potential race where multiple
219 // calls to reinit() can happen concurrently.
220 //
221 // Also note that if this fails, the old InputGuard will be visible to
222 // the panic hook, which is fine -- since we called restore() first, the
223 // terminal state is already restored and guard is None.
224 let mut locked = self
225 .guard
226 .lock()
227 .map_err(|_| InputHandlerCreateError::Poisoned)?;
228 let guard = imp::InputGuard::new().map_err(InputHandlerCreateError::EnableNonCanonical)?;
229 *locked = guard;
230 Ok(())
231 }
232
233 fn restore(&self) -> Result<(), InputHandlerFinishError> {
234 // Do not panic here, in case a panic happened while the thread was
235 // locked. Instead, ignore the error.
236 let mut locked = self
237 .guard
238 .lock()
239 .map_err(|_| InputHandlerFinishError::Poisoned)?;
240 locked.restore().map_err(InputHandlerFinishError::Restore)
241 }
242}
243
244// Defense in depth -- use both the Drop impl (for regular drops and
245// panic=unwind) and a panic hook (for panic=abort).
246impl Drop for InputHandlerImpl {
247 fn drop(&mut self) {
248 if let Err(error) = self.restore() {
249 eprintln!(
250 "failed to restore terminal state: {}",
251 DisplayErrorChain::new(error)
252 );
253 }
254 }
255}
256
257#[derive(Debug, Error)]
258enum InputHandlerCreateError {
259 #[error("failed to enable terminal non-canonical mode")]
260 EnableNonCanonical(#[source] imp::Error),
261
262 #[cfg(unix)]
263 #[error("mutex was poisoned while reinitializing terminal state")]
264 Poisoned,
265}
266
267#[derive(Debug, Error)]
268enum InputHandlerFinishError {
269 #[error("mutex was poisoned while restoring terminal state")]
270 Poisoned,
271
272 #[error("failed to restore terminal state")]
273 Restore(#[source] imp::Error),
274}
275
276#[cfg(unix)]
277mod imp {
278 use libc::{ECHO, ICANON, TCSAFLUSH, TCSANOW, VMIN, VTIME, tcgetattr, tcsetattr};
279 use std::{
280 ffi::c_int,
281 io::{self, IsTerminal},
282 mem,
283 os::fd::AsRawFd,
284 };
285 use tracing::debug;
286
287 pub(super) type Error = io::Error;
288
289 pub(super) fn is_foreground_process() -> bool {
290 if !std::io::stdin().is_terminal() {
291 debug!("stdin is not a terminal => is_foreground_process() is false");
292 return false;
293 }
294
295 // Also check that tcgetpgrp is the same. If tcgetpgrp fails, it'll
296 // return -1 and this check will fail.
297 //
298 // See https://stackoverflow.com/a/2428429.
299 let pgrp = unsafe { libc::getpgrp() };
300 let tc_pgrp = unsafe { libc::tcgetpgrp(std::io::stdin().as_raw_fd()) };
301 if tc_pgrp == -1 {
302 debug!(
303 "stdin is a terminal, and pgrp = {pgrp}, but tcgetpgrp failed with error {} => \
304 is_foreground_process() is false",
305 io::Error::last_os_error()
306 );
307 return false;
308 }
309 if pgrp != tc_pgrp {
310 debug!(
311 "stdin is a terminal, but pgrp {} != tcgetpgrp {} => is_foreground_process() is false",
312 pgrp, tc_pgrp
313 );
314 return false;
315 }
316
317 debug!(
318 "stdin is a terminal, and pgrp {pgrp} == tcgetpgrp {tc_pgrp} => \
319 is_foreground_process() is true"
320 );
321 true
322 }
323
324 /// A scope guard to enable non-canonical input mode on Unix platforms.
325 ///
326 /// Importantly, this does not enable the full raw mode that crossterm
327 /// provides -- that disables things like signal processing via the terminal
328 /// driver, which is unnecessary for our purposes. Here we only disable
329 /// options relevant to the input: echoing and canonical mode.
330 #[derive(Clone, Debug)]
331 pub(super) struct InputGuard {
332 // None indicates that the original state has been restored -- only one
333 // entity should do this.
334 //
335 // Note: originally, this used nix's termios support, but that was found
336 // to be buggy on illumos (lock up the terminal) -- apparently, not all
337 // bitflags were modeled. Using libc directly is more reliable.
338 original: Option<libc::termios>,
339 }
340
341 impl InputGuard {
342 pub(super) fn new() -> io::Result<Self> {
343 let TermiosPair { original, updated } = compute_termios()?;
344 stdin_tcsetattr(TCSAFLUSH, &updated)?;
345
346 // Ignore SIGTTIN and SIGTTOU while input handling is active. This
347 // prevents the process from being stopped if a test spawns an
348 // interactive shell that takes over the foreground process group.
349 //
350 // This is what zsh does for job control:
351 // https://github.com/zsh-users/zsh/blob/3e72a52/Src/init.c#L1439
352 //
353 // See https://github.com/nextest-rs/nextest/issues/2878.
354 unsafe {
355 libc::signal(libc::SIGTTIN, libc::SIG_IGN);
356 libc::signal(libc::SIGTTOU, libc::SIG_IGN);
357 }
358
359 Ok(Self {
360 original: Some(original),
361 })
362 }
363
364 pub(super) fn restore(&mut self) -> io::Result<()> {
365 if let Some(original) = self.original.take() {
366 // Restore terminal state. SIGTTIN/SIGTTOU remain ignored for
367 // the process lifetime to avoid racing with crossterm's input
368 // thread shutdown.
369 stdin_tcsetattr(TCSANOW, &original)
370 } else {
371 Ok(())
372 }
373 }
374 }
375
376 fn compute_termios() -> io::Result<TermiosPair> {
377 let mut termios = mem::MaybeUninit::uninit();
378 let res = unsafe { tcgetattr(std::io::stdin().as_raw_fd(), termios.as_mut_ptr()) };
379 if res == -1 {
380 return Err(io::Error::last_os_error());
381 }
382
383 // SAFETY: if res is 0, then termios has been initialized.
384 let original = unsafe { termios.assume_init() };
385
386 let mut updated = original;
387
388 // Disable echoing inputs and canonical mode. We don't disable things like ISIG -- we
389 // handle that via the signal handler.
390 updated.c_lflag &= !(ECHO | ICANON);
391 // VMIN is 1 and VTIME is 0: this enables blocking reads of 1 byte
392 // at a time with no timeout. See
393 // https://linux.die.net/man/3/tcgetattr's "Canonical and
394 // noncanonical mode" section.
395 updated.c_cc[VMIN] = 1;
396 updated.c_cc[VTIME] = 0;
397
398 Ok(TermiosPair { original, updated })
399 }
400
401 #[derive(Clone, Debug)]
402 struct TermiosPair {
403 original: libc::termios,
404 updated: libc::termios,
405 }
406
407 fn stdin_tcsetattr(optional_actions: c_int, updated: &libc::termios) -> io::Result<()> {
408 let res = unsafe { tcsetattr(std::io::stdin().as_raw_fd(), optional_actions, updated) };
409 if res == -1 {
410 Err(io::Error::last_os_error())
411 } else {
412 Ok(())
413 }
414 }
415}
416
417#[cfg(windows)]
418mod imp {
419 use std::{
420 io::{self, IsTerminal},
421 os::windows::io::AsRawHandle,
422 };
423 use tracing::debug;
424 use windows_sys::Win32::System::Console::{
425 CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, GetConsoleMode, SetConsoleMode,
426 };
427
428 pub(super) type Error = io::Error;
429
430 pub(super) fn is_foreground_process() -> bool {
431 // Windows doesn't have a notion of foreground and background process
432 // groups: https://github.com/microsoft/terminal/issues/680. So simply
433 // checking that stdin is a terminal is enough.
434 //
435 // This function is written slightly non-idiomatically, because it
436 // follows the same structure as the more complex Unix function above.
437 if !std::io::stdin().is_terminal() {
438 debug!("stdin is not a terminal => is_foreground_process() is false");
439 return false;
440 }
441
442 debug!("stdin is a terminal => is_foreground_process() is true");
443 true
444 }
445
446 /// A scope guard to enable raw input mode on Windows.
447 ///
448 /// Importantly, this does not mask out `ENABLE_PROCESSED_INPUT` like
449 /// crossterm does -- that disables things like signal processing via the
450 /// terminal driver, which is unnecessary for our purposes. Here we only
451 /// disable options relevant to the input: `ENABLE_LINE_INPUT` and
452 /// `ENABLE_ECHO_INPUT`.
453 #[derive(Clone, Debug)]
454 pub(super) struct InputGuard {
455 original: Option<CONSOLE_MODE>,
456 }
457
458 impl InputGuard {
459 pub(super) fn new() -> io::Result<Self> {
460 let handle = std::io::stdin().as_raw_handle();
461
462 // Read the original console mode.
463 let mut original: CONSOLE_MODE = 0;
464 let res = unsafe { GetConsoleMode(handle, &mut original) };
465 if res == 0 {
466 return Err(io::Error::last_os_error());
467 }
468
469 // Mask out ENABLE_LINE_INPUT and ENABLE_ECHO_INPUT.
470 let updated = original & !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT);
471
472 // Set the new console mode.
473 let res = unsafe { SetConsoleMode(handle, updated) };
474 if res == 0 {
475 return Err(io::Error::last_os_error());
476 }
477
478 Ok(Self {
479 original: Some(original),
480 })
481 }
482
483 pub(super) fn restore(&mut self) -> io::Result<()> {
484 if let Some(original) = self.original.take() {
485 let handle = std::io::stdin().as_raw_handle();
486 let res = unsafe { SetConsoleMode(handle, original) };
487 if res == 0 {
488 Err(io::Error::last_os_error())
489 } else {
490 Ok(())
491 }
492 } else {
493 Ok(())
494 }
495 }
496 }
497}
498
499#[derive(Copy, Clone, Debug, Eq, PartialEq)]
500pub(crate) enum InputEvent {
501 Info,
502 Enter,
503}