Rolf: User Input, Threads, and Channels

Assume that you’re writing a TUI application (like rolf) which does at least two things:

  1. Respond to user input, and

  2. Load in incredibly large[1] images.

If you’re working on a program like this, you might run into an annoying problem: users have to wait for images to load before they can do anything.

The solution to this problem is to asynchronously obtain user input.

Depending on your runtime (e.g. nodejs), it’s possible to be both asynchronous and single-threaded.

However, for the purposes of rolf, we used multiple threads to handle asynchronous input.

Architecture

For the purposes of this post, let’s say that there are 3 threads involved.[2]

threads

As seen in the diagram above, we have a thread for:

  • Receiving user input,

  • Decoding images,

  • And everything else (the Main thread)

You might also notice that the Input and Image Decoding threads only interact with the Main thread, sending data back and forth to each other.

The Input and Main threads will both run on "infinite" loops. The Input thread will wait for user input, sending it to the Main thread as necessary, and the Main thread will wait for either input data from the Input thread or decoded image data from the Image Decoding thread, processing this data as necessary.

Notably, we achieve all of this without polling, instead using channels to send data between threads.

Our Main thread will simply be the initial thread of the program, and our two other threads will be started by the Main thread.

In this post, we’ll be looking at the relationship between the Input thread and Main thread. The Image Decoding thread is sophisticated enough to warrant a post of its own, which I may write in the future.

Channels

Before starting the loops for either of the threads (in fact, before starting any additional threads beyond the Main one), we’re going to create a few channels.

1
2
3
let (tx, rx) = channel();

let (to_input_tx, from_main_rx) = sync_channel(0);

Here, we’ve created two channels:

  • One which we’ll use for sending data to the Main thread,

  • And another which we’ll use for sending data to the Input thread.

Since channels only send information in one direction, we need two channels if we want bidirectional communication between the Input thread and the Main thread. Why do we want bidirectional communication in the first place? We’ll discuss that more later.

From our point of view, each channel consists of a sender and a receiver.

  • For the first channel, tx will be our sender and rx will be our receiver.

  • For the second channel, to_input_tx is our sender and from_main_rx is our receiver.

Notably, we use a different function to create each of our channels. I’m not sure if this is actually necessary, but this allows us to create asynchronous/infinitely buffered vs. synchronous/bounded channels. The distinction won’t matter massively for the purposes of this blog post, but you can read more about it here.

Data Between Channels

Before we actually use the channels to send data between threads, let’s first establish the types of data that will be sent.

1
2
3
4
5
6
7
enum InputEvent {
    CrosstermEvent {
        event: crossterm::event::Event,
        input_request_count: usize,
    },
    // Other events...
}
1
2
3
4
enum InputRequest {
    RequestNumber(usize),
    Quit,
}

The first enum we defined will be sent from the Input thread to the Main thread.

The second enum is sent from the Main thread to the Input thread. This implies that the Main thread must request input from the Input thread before the Input thread actually sends any input. This is the primary reason we have bidirectional communication between the Main and Input threads, and it allows us to avoid a potentially annoying bug.

Bug: Too Many Inputs

In rolf, there’s a feature which allows the user to open up their current file in an external text editor of their choice (using the EDITOR and VISUAL environmental variables). Typical values of EDITOR might be nano, vim, or emacsclient. For this feature, the Main thread will block until the external text editor is closed.[3]

Now imagine if we didn’t have the Main thread request input from the Input thread, instead making the Input thread send input through its infinitely-buffered channel as soon as it receive any from the user.

If we combine this channel setup with the external editor feature described above, we’ll run into a curious problem: the Input thread may "steal" keypresses from the external editor.

Since the Input thread will just receive input and send it to the Main thread whenever it receives a keypress, it can occasionally continue receiving these inputs even if, say, vim is being used to edit the current file. As a result, two annoying things will happen:

  1. These occasional inputs won’t make it to vim, resulting in the user being unsettled by the inconsistent (in)ability to provide input.

  2. These occasional inputs will be sent to the main thread, which will then process those inputs after the external editor is closed. This would also be disjarring for the user, as they would see apparent "phantom inputs" be rapidly processed after they finish editing.

By making the Main Thread send requests for input to the Input thread, we can ensure that the Input thread only sends input to the Main thread when necessary. This prevents the above bug from happening entirely.

You might’ve also noticed that both InputRequest and CrosstermEvent contain request numbers. These request numbers help the Main thread determine whether or not it should actually send a request for input, which is crucial for properly fixing this bug. We’ll dive more into that later.

Starting the Input Thread

Now that we have the channels and their data set up, it’s time to start the input loop.

Specifically, we’ll start the input loop in its own thread, the Input thread.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
let crossterm_input_tx = tx.clone();

std::thread::spawn(move || {
    loop {
        let input_request_count = match from_main_rx.recv() {
            Ok(InputRequest::RequestNumber(input_request_count)) => input_request_count,
            Ok(InputRequest::Quit) => break,
            Err(err) => panic!("Input thread: Lost connnection to main thread: {:?}", err),
        };

        let crossterm_event = event::read().expect("Unable to read crossterm event");

        crossterm_input_tx
            .send(InputEvent::CrosstermEvent {
                event: crossterm_event,
                input_request_count,
            })
            .expect("Unable to send on channel");
    }
});

Here, to satisfy the Rust borrow checker, we need to clone our sender before we use it in the actual thread. Since these channels are multi-producer, single-consumer, this is how we handle having more than one possible sender (producer) to the Main thread.

The rest of the thread is mostly what you’d expect, with a bit of bookkeeping for request numbers and requests to quit the thread.

Notably, the Input thread keeps track of the request number of the most recent input request sent to it by the Main thread, and then it sends this number back to the Main thread alongside the actual input data.

This implies the Main thread will be the only thread to modify request numbers at all. We’ll see more about this in a bit.

Loop in the Main Thread

As the name might indicate, the Main thread is where the real meat and bones of rolf takes place. Since they can get fairly complex, we’ll omit most of those details, only showing the parts that are relevant to managing concurrency.

 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
let mut input_request_count = 0;
let mut last_recv_req_count = 0;

// Other initialization code...

loop {
    // Main drawing code...

    // Other stuff...

    let event = match rx.try_recv() {
        Ok(event) => event,
        Err(TryRecvError::Empty) => {
            if input_request_count == last_recv_req_count {
                input_request_count += 1;
                to_input_tx
                    .send(InputRequest::RequestNumber(input_request_count))
                    .expect("Unable to send to input thread");
            }

            rx.recv().unwrap()
        }
        Err(err) => panic!("Unable to obtain input event: {}", err),
    };

    match event {
        InputEvent::CrosstermEvent {
            event,
            input_request_count,
        } => {
            last_recv_req_count = input_request_count;

            // Process input from user...
        }
        // Handle other events...
    }
}

As mentioned earlier, the Main thread handles updates request numbers and determines if it should send an input request.

When determining if we should send an input request, there are two key things to keep in mind:

  1. We should only ask for a keypress if there’s no other input or data to process.

  2. We should only ask for a keypress if the Input thread has already responded to our last input request.

In practice, the first item means only asking for a keypress if there are no existing InputEvent values in the channel to the main thread. This is precisely what lines 11-24 of the above code block are achieving.

To handle the second item, we need to keep track of two key things:

  • The request number of the most recently sent input request (input_request_count).

  • And the request number associated with the last CrosstermEvent received by the Main thread from the Input thread (last_recv_req_count).

By comparing request numbers for equality, we can see which CrosstermEvent values were prompted by which input requests. Thus, to see if the Input thread has already responded to our last input request, we can check if the input_request_count number is equal to the last_recv_req_count number.

Possible Bug: Integer Overflow

You might’ve noticed that we’re just incrementing input_request_count every time we make a new input request.

If we continue doing that for long enough, the value of input_request_count will surpass the maximum value of a 64-bit unsigned integer, resulting in integer overflow.

There’s a simple fix to this problem: wrapping the integer.

  1. Before incrementing, check if input_request_count is at the maximum value for its type.

  2. If it is, then set input_request_count back to 0.

  3. Otherwise, increment it as normal.

In practice, I haven’t run into this bug yet (due to the incredibly high number of CrosstermEvent values which would have to be sent to trigger it).

In release builds with Rust, integers will wrap around using two’s complement, so this bug won’t really be visible. If I understand this correctly, two’s complement wraparound will in effect achieve the wrapping algorithm I described above.

In debug builds, overflow will be checked at runtime, with the program panicking if overflow does occur. I probably won’t ever use rolf enough to trigger this bug in a debug build (or in any build, really).

Fortunately, if this does turn out to be an issue, the fix should be fairly simple.

Parting Thoughts

It took me a crazy long time to figure out how to address the input "stealing" bug. Arguably, finding that bug (in addition to the asynchronous image loading problem) was one of the leading factors which led me to reshape the architecture of rolf into what it is now.

If you ever run into a similar problem, I hope this is useful to you!

Note: Have I made any typos? Am I blatantly wrong about something? Do you just have general feedback? I’m still a Rust novice (arguably a programming novice, in general), so feel free to email me at chiggiechang@gmail.com. But be polite, please!


1. Rolf doesn’t necessarily load insanely large images, but there can be a noticeable loading time. Going forward, we might want to use a faster JPEG decoder.
2. rolf actually uses more threads than the ones shown here, but they’re not relevant to this post.
3. Specifically, the Main thread will block until the child process spawned by it to open up the external editor is closed.