Input handling¶
Design document
This is a design document intended for nextest contributors and curious readers.
Currently, nextest handles keyboard inputs in interactive terminals:
- On
t
being pressed, nextest prints the status and captured output for currently-running tests. - On Enter being pressed, nextest prints either a progress bar snapshot if the bar is visible, or a blank line if the bar is not visible.
This page describes why nextest handles keyboard inputs, and how it works.
Background: terminals and tty drivers
When a user types a key in a terminal, the keypress is sent to the tty driver (also known as the line discipline). This is different from your terminal emulator, such as gnome-terminal, iTerm2, or Windows Terminal1: these programs merely relay keypresses to the tty driver.
For an introduction to terminals and tty drivers (stty
), see WezTerm's What
is a Terminal?. The
rest of this page assumes you're familiar with these concepts.
Summary¶
- Nextest handles keyboard inputs for a better user experience, and to match functionality across all platforms.
- Both on Unix and on Windows, nextest modifies terminal settings to disable line buffering and echoing.
- Inputs are translated into Tokio events and passed to the dispatcher.
Why handle inputs?¶
Some Unix platforms provide a signal, SIGINFO
, that can be sent to a process
to request status information. SIGINFO
is available on BSD-based platforms
including macOS, as well as on illumos.
SIGINFO
has several properties that make it well-suited for status reporting:
-
By default, when processes receive
SIGINFO
, they ignore it. This is different from the default for most other signals (likeSIGUSR1
), which is to terminate the process.This means that in practice, it is virtually always safe to send a process
SIGINFO
. That's true even if you aren't sure whether the process understands the signal.
- In interactive use, it is easy to send a process
SIGINFO
: simply press Ctrl-T, and the tty driver will sendSIGINFO
to the foreground process group. For signals likeSIGUSR1
, you need to know the process ID and runkill -USR1 $PID
.
Several command-line tools support SIGINFO
, like ping
on macOS:
$ ping 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=115 time=7.866 ms
(^T pressed)
load: 2.18 cmd: ping 4927 running 0.00u 0.00s
1/1 packets received (100.0%) 7.866 min / 7.866 avg / 7.866 max
64 bytes from 8.8.8.8: icmp_seq=1 ttl=115 time=7.735 ms
...
As part of its signal handling, nextest supports SIGINFO
on platforms where it is available. Unfortunately, SIGINFO
and Ctrl-T are not
available on either Linux or Windows. So we must improvise to provide similar
functionality. For interactive uses, accepting terminal input from the keyboard
is a straightforward way to do that.
For a uniform cross-platform experience, nextest handles inputs the same way on
all platforms. On platforms where SIGINFO
is available, both Ctrl-T and t
print the same status.
Nextest also handles the Enter key for a more subtle UX reason, described in Handling the Enter key below.
In the future, keyboard handling could be extended to support other commands as well.
Overview¶
Input handling in nextest has several components:
- Terminal settings: Nextest modifies terminal settings to disable line buffering and echoing. This allows it to read individual characters as they are typed in, and to not display them on the screen.
- Event stream: Nextest uses crossterm's
EventStream
to get a stream of keyboard events into Tokio. - Dispatcher control: The runner loop's dispatcher is responsible for asynchronously polling the input handler, and for other actions like restoring terminal settings on
SIGTSTP
.
The following flowchart shows how nextest initializes the input handler and processes inputs, ensuring robustness and reliability across a variety of conditions:
graph TB
start([initialize input handler]) --> check_tty{is stdin interactive?}
check_tty -->|no| normal[don't modify terminal settings]
normal --> end_state_1([end])
check_tty -->|yes| modify[modify terminal settings, initialize event stream]
modify --> dispatcher_control{{hand control to dispatcher}}
handle_input --> |other keys ignored| dispatcher_control
dispatcher_control --> |poll for input| handle_input{which key?}
handle_input --> |recognized key| return_event[return event]
return_event --> dispatcher_control
dispatcher_control ---> |SIGTSTP| temp_restore[temporarily restore terminal settings, reapply on SIGCONT]
temp_restore --> dispatcher_control
dispatcher_control --> |finish or drop| restore[restore terminal settings]
restore --> end_state([end])
In case of an error at any point, nextest prints out a warning and disables input handling. Errors are quite rare, though: if stdin is interactive, the terminal settings can be successfully modified.
This state machine is implemented in nextest-runner/src/input.rs
.
An attempt at restoring original settings is done in both a Drop
implementation (i.e. a scope guard) and a panic hook, to hopefully catch all
abnormal exits.
Input handling on Unix¶
This section describes how nextest handles inputs like t
on Unix platforms.
Line buffering and canonical mode¶
To handle t
in the terminal, you might assume that something like this would
work:
loop {
// Read next character.
let mut buf = [0u8; 1];
std::io::stdin().read_exact(&mut buf)?;
if buf[0] == b't' {
// Print status.
println!("status: {:?}", status);
}
}
However, this code will not work as expected. This is because by default, the tty driver is in what is called canonical mode. In this mode, the tty driver makes input available line by line; this is also called "line-oriented" or "line-buffered"2. As this glibc page explains:
In canonical input processing mode, terminal input is processed in lines terminated by newline (
'\n'
), EOF, or EOL characters. No input can be read until an entire line has been typed by the user, and the read function […] returns at most a single line of input, no matter how many bytes are requested.
So by default, simply pressing t
wouldn't do anything. The user would have to
press Enter to make the t
key available to nextest. That isn't a great user
experience.
Changing terminal settings: raw mode¶
Can the tty driver be instructed to not perform line buffering? Yes, and TUI
applications do this. With an editor like vim or Emacs, for example, or even in
simpler commands like top
, typed-in keys are processed immediately.
Changing the tty driver's behavior is achieved via the termios interface
(Linux man page, FreeBSD overview, FreeBSD API, illumos overview). Most
TUI applications use raw mode, as accessed by the cfmakeraw
API. In raw
mode:
- Input is available character by character.
- When a character is typed in, it is no longer echoed on the screen.
- The tty driver no longer does any special processing of terminal input or output.
Effectively, raw mode is the program telling the tty driver that it wants to handle everything. As such, raw mode is a great fit for TUI applications.
Unlike vim or Emacs, though, nextest is not a TUI application. cargo nextest
run
does not take over the whole screen. The full raw mode doesn't quite work
for nextest. For details on why raw mode is not suitable for nextest, see the
appendix.)
termios
flags¶
Luckily, termios
comes with both the cfmakeraw
API and individually-adjustable knobs. Most of them are exposed as bitflags. The full set of flags differs by Unix platform (the Linux man page for termios
has one list), but the ones we care about disabling are available on all Unixes:
ICANON
- Canonical mode, i.e. line buffering. This is what we set out to disable.
ECHO
- Print characters as they're typed. Not echoing characters results in a nicer user experience as detailed in the appendix, so we disable it.
There are also a couple of numerical settings that need to be adjusted:
VMIN = 1
- In non-canonical mode, the number of characters to read at a time from stdin. We want to read individual characters like
t
as soon as they come in, so this should be 1. VTIME = 0
- In non-canonical mode, the number of deciseconds (tenths of a second) to wait for input before returning. This is useful for implementing timeouts. But nextest already uses Tokio to handle timeouts, so this is not needed. Setting
VTIME
to 0 disables timeouts.
These are the only terminal settings nextest needs to change to get its
preferred behavior. In particular, the ISIG
flag controls Ctrl-C and Ctrl-Z
processing, and we leave it enabled.
A summary of settings (yes means a feature is handled by the tty driver, no means that the program must handle it):
behavior | default settings | raw mode | nextest's mode |
---|---|---|---|
input processing | line-buffered | unbuffered | unbuffered |
input echoing | yes | no | no |
special key processing (Ctrl-C etc.) | yes | no | yes |
progress bar (\r handling) |
yes | no | yes |
misc settings like flow control | yes | no | yes |
A note on termios
APIs in Rust¶
The termios
APIs are diff-driven. To change terminal settings, you are
supposed to:
- Load the current settings via
tcgetattr
into atermios
struct. - Modify the settings on the
termios
struct, either by callingcfmakeraw
as TUI applications do, or by changing specific flags as nextest does. - Apply the modified
termios
struct viatcsetattr
.
This pattern ensures forward compatibility with new termios
flags that may be
added in the future. Flags not known to the program are supposed to be left
untouched.
On the flipside, any models of termios
that try to list out all of the flags
upfront are incorrect.
- In particular, Rust enums do not allow this kind of
open-ended forward compatibility. Modeling
termios
flags as a set of enums is generally incorrect. They could be made correct by carefully restricting the interface, but enums seem like the wrong tool for the job. - The
bitflags
crate is okay, if the unknown bits are put in an externally-defined flag. - Another option is storing bits on an integer of some kind, like in C.
We choose the last option, directly using libc's termios
APIs.
It is a small amount of unsafe code, but it is isolated and easy to audit. Using
libc directly is also more portable: termios
wrappers might not support all
Unix platforms, but libc does.
Job control and input handling¶
Nextest handles the SIGTSTP
and SIGCONT
signals: see Job control for more.
On receiving SIGTSTP
(Ctrl-Z), the dispatcher instructs the input handler to
temporarily restore terminal settings to their original values. This is done
right before nextest itself stops and control is handed back to the shell.
On receiving SIGCONT
(after fg
), the input handler is instructed to reapply
the modified terminal settings. We reuse the same input handler rather than
setting up a new one, so that we don't have to install a new panic hook.
Input handling on Windows¶
Windows console and Unix terminal handling are surprisingly similar in this regard:
- The console is line-oriented by default.
- The equivalent to termios is the console mode.
tcgetattr
andtcsetattr
areGetConsoleMode
andSetConsoleMode
, respectively.- The equivalent of raw mode is achieved by tweaking a few flags, as crossterm's code to enable raw mode on Windows shows.
The flags disabled by crossterm are:
ENABLE_LINE_INPUT
- Similar to
ICANON
, disabling this flag switches the console to character mode. Nextest disables it. ENABLE_ECHO_INPUT
- Similar to
ECHO
, this flag controls input echoing. Nextest disables it as well. ENABLE_PROCESSED_INPUT
- Similar to
ISIG
, this flag controls whether Ctrl-C is sent as an interrupt. Like on Unix, nextest leaves this flag untouched.
The Windows implementation lives next to the Unix one, in
nextest-runner/src/input.rs
.
Future directions on Windows¶
Windows's current handling of Ctrl-C is quite basic. In the future, we may want to disable processed input on Windows, in conjunction with improved signal handling.
Handling the Enter key¶
As discussed in the appendix below, echoing characters is disabled for a better user experience. However, users sometimes want characters to be echoed.
For example, a common pattern to mark a spot in some interactive output is to press Enter a bunch. (This is safe to do with programs that don't consume input, because those lines are treated as no-ops by the shell.)
Nextest supports this pattern by handling the Enter key. On receiving Enter, nextest displays the current state as would be visible in the progress bar, but not the progress bar itself. This acts as both a report of the current state and as a visual marker.
For example, nextest might display:
PASS [ 0.749s] integration-tests::integration test_target_arg
(Enter pressed)
Running [ 00:00:02] 293/296: 3 running, 293 passed, 1 skipped
PASS [ 2.160s] nextest-runner::integration basic::test_termination
PASS [ 0.749s] integration-tests::integration test_target_arg
(Enter pressed)
Running [ 00:00:02] 293/296: 3 running, 293 passed, 1 skipped
PASS [ 2.160s] nextest-runner::integration basic::test_termination
Compare this behavior to what happens without input handling:
- If the progress bar is visible, a snapshot of the progress bar is printed.
- If not, a blank line is printed.
Nextest's behavior with input handling is user-friendly, and consistent regardless of whether the progress bar is visible.
Translating inputs to events¶
To translate keypresses into Tokio events, nextest currently relies on
crossterm's EventStream
abstraction. This returns a stream of
keyboard events. Nextest maps recognized keys to an internal enum that is passed
to the dispatcher, and ignores unrecognized keys (and other events).
// stream is an EventStream.
let next = stream.next().await?;
match next {
Ok(Event::Key(key)) => {
// Handle recognized keys.
if key.code == KeyCode::Char(Self::INFO_CHAR) && key.modifiers.is_empty() {
return Some(InputEvent::Info);
}
if key.code == KeyCode::Enter {
return Some(InputEvent::Enter);
}
}
_ => {
// Ignore unrecognized events.
}
}
EventStream
works well and there isn't a real need to do anything different,
so this section is brief.
If we decide to ever write our own implementation, something to note is that
EventStream
is runtime-agnostic. This makes it somewhat more complicated than
an implementation that spawns a blocking task via
tokio::task::spawn_blocking
. Since nextest doesn't
need to be runtime-agnostic, a simpler implementation on top of crossterm's
synchronous APIs is likely possible.
Conclusion¶
Nextest's approach to input handling prioritizes cross-platform consistency and an good user experience. By carefully managing terminal settings and input events in a principled manner, nextest ensures a robust and extensible foundation for interactive functionality.
Appendix: why raw mode doesn't work for nextest¶
Let's go over what raw mode does, and see which bits make sense for nextest:
- Input is available character by character. This is the goal we're trying to achieve.
- When a character is typed in, it is no longer echoed on the screen. This is good. It leads to a better UX for a surface-level reason, and also for a more subtle reason.
The surface-level reason: echoing keys results in nextest's output being misaligned. If keys like
t
are not echoed, the output stays properly aligned.The more subtle reason: in user interviews, it was found that some users have the habit of typing out the next command in the middle of the previous command running. For example, in the middle of a
cargo nextest run
, they might type outcargo test --doc
. If nextest (or any other process) reads from standard input, this is no longer possible. But echoing characters on the screen suggests that keystrokes are being saved (users will pattern-match from all the other places they do that), creating a misleading impression3.Echoing characters is sometimes useful for other reasons, though. See Handling the Enter key above for more.
- The tty driver no longer does any special processing of input… This means
that, for example, pressing Ctrl-C would no longer send a
SIGINT
signal to the process. Nextest would be responsible for handling Ctrl-C itself, and treating it as similar toSIGINT
if so desired.This results in some additional complexity¸ because nextest would need to handle these keys as they come in.
It also results in some loss of functionality: for example, the user can customize the keys used to send signals to a process via the
stty
command. In raw mode, these settings would no longer be honored.Finally, it results in slightly lower reliability: if something goes wrong in nextest's input processing, signals like Ctrl-C might not work as expected. Letting the tty driver handle these signals is safer.
All of these are manageable, but they are downsides worth avoiding.
- …or output. This is a major problem: it interferes with progress bar output.
With the progress bar library we use, indicatif, the bar is redrawn by printing a
\r
(CR or carriage return) character.In regular mode, the
\r
moves the cursor to the beginning of the line, allowing it to be written over.In raw mode, this does not happen. The
\r
character is printed as-is, and updates to the progress bar are printed after that.
To summarize: raw mode does a bit too much, and we need to find some kind of middle ground between the default driver settings and raw mode.
Last substantive revision: 2025-01-01
-
On Windows, the closest equivalent to the tty driver is the console host. The equivalent to modifying terminal settings is changing the console mode. ↩
-
But note that canonical-mode line buffering is independent of the buffering done by functions like
setbuf
. By default, standard input streams are unbuffered in Rust, but the tty driver still buffers input. ↩ -
For this reason, users may wish to disable the input handler entirely. This is supported by passing in
--no-input-handler
, or settingNEXTEST_NO_INPUT_HANDLER=1
in the environment. ↩