Rust
async programming
stdin input
cancel-safe
Rust programming

How to detect stdin input in a cancel-safe way in Rust async?

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

In async Rust, the tricky part is not reading from stdin itself. The tricky part is doing it in a way that behaves correctly inside cancellation points such as tokio::select!. If a read operation is not cancel-safe, a competing branch can win and partially consumed input may be lost.

Why Cancellation Safety Matters

In async code, cancellation often happens implicitly. A future inside tokio::select! is dropped as soon as another branch completes first. That is fine only if the dropped future can be restarted without corrupting state.

With line-oriented input, this distinction matters a lot:

  • cancel-safe read: no input is silently lost if the future is dropped
  • non-cancel-safe read: bytes may have been consumed already, but your program never receives them

For interactive programs, that can lead to confusing missing input or commands that seem to vanish.

read_line Is the Wrong Tool Inside select!

Tokio's read_line is convenient, but it is not cancellation-safe for this use case. If a different branch in select! completes first, the future may be canceled after partially reading data.

Instead, Tokio documents safer alternatives:

  • 'lines().next_line()'
  • 'read_until(b'\\n', ...) plus manual UTF-8 handling'
  • a codec such as LinesCodec

A Cancel-Safe Pattern With next_line

Here is a straightforward Tokio example using BufReader and next_line:

rust
1use tokio::io::{self, AsyncBufReadExt, BufReader};
2use tokio::signal;
3
4#[tokio::main]
5async fn main() -> io::Result<()> {
6    let stdin = BufReader::new(io::stdin());
7    let mut lines = stdin.lines();
8
9    loop {
10        tokio::select! {
11            line = lines.next_line() => {
12                match line? {
13                    Some(text) => {
14                        println!("got input: {}", text);
15                        if text == "quit" {
16                            break;
17                        }
18                    }
19                    None => {
20                        println!("stdin closed");
21                        break;
22                    }
23                }
24            }
25            _ = signal::ctrl_c() => {
26                println!("shutdown requested");
27                break;
28            }
29        }
30    }
31
32    Ok(())
33}

This works well because next_line on the lines() stream is designed for cancellation-safe usage in this pattern.

When spawn_blocking Is Simpler

Console input is still an awkward platform boundary in some environments. If your program is mostly async but standard input is just a small control channel, pushing that work into a blocking thread is sometimes the cleanest design.

rust
1use std::io::{self, BufRead};
2use tokio::sync::mpsc;
3
4#[tokio::main]
5async fn main() {
6    let (tx, mut rx) = mpsc::unbounded_channel();
7
8    tokio::task::spawn_blocking(move || {
9        let stdin = io::stdin();
10        for line in stdin.lock().lines() {
11            match line {
12                Ok(text) => {
13                    if tx.send(text).is_err() {
14                        break;
15                    }
16                }
17                Err(_) => break,
18            }
19        }
20    });
21
22    while let Some(line) = rx.recv().await {
23        println!("received: {}", line);
24        if line == "quit" {
25            break;
26        }
27    }
28}

This avoids mixing tricky console semantics with your async task scheduling. It is not "more async", but it is often more robust.

Raw Byte Handling With read_until

If you need tighter control over cancellation and encoding, read_until can be a good fit because it works with bytes first:

rust
1use tokio::io::{self, AsyncBufReadExt, BufReader};
2
3async fn read_one_line() -> io::Result<Option<String>> {
4    let stdin = BufReader::new(io::stdin());
5    let mut reader = stdin;
6    let mut buf = Vec::new();
7
8    let n = reader.read_until(b'\n', &mut buf).await?;
9    if n == 0 {
10        return Ok(None);
11    }
12
13    let text = String::from_utf8_lossy(&buf).trim_end().to_string();
14    Ok(Some(text))
15}

This is more verbose, but it makes the buffering model explicit.

Choose Based on the Real Requirement

Use lines().next_line() when:

  • you want line-based input
  • you are already using Tokio
  • you need safe behavior inside select!

Use spawn_blocking when:

  • standard input is a small part of the program
  • portability and simplicity matter more than pure async style
  • you want to keep console handling isolated from async logic

Common Pitfalls

The most common mistake is using read_line directly inside tokio::select! and assuming it is cancellation-safe. Another is treating standard input like a high-throughput async socket when it is really just a blocking terminal stream with awkward platform behavior.

Developers also sometimes forget that cancellation safety is about what happens when a future is dropped halfway through work. If you do not design around that, input can disappear in subtle ways.

Summary

  • Cancellation safety matters whenever stdin reads compete with other async branches.
  • 'read_line is not the best choice inside tokio::select! for cancel-safe behavior.'
  • Tokio's lines().next_line() is a practical cancel-safe line reader.
  • 'spawn_blocking is often a reasonable design for console input in async programs.'
  • Pick the approach that protects input correctness, not just the one that looks the most "fully async".

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.