Pipe a file into less and it still responds when you press j. But if less is reading the file from stdin, where is it reading your keystrokes from?
I ran into the same question while connecting piped input to an interactive program. By the time the interactive program started, fd 0 was an exhausted pipe. Tools like less and fzf showed that getting the keyboard back was possible: fzf reads its entire candidate list from a pipe and still lets you type to filter it. But I did not know how they did it. Here’s what I found.
The experiment
The program is one binary piped into itself four times:
./target/debug/feat-test | ./target/debug/feat-test | ./target/debug/feat-test | ./target/debug/feat-testEach stage waits for its turn, reads one line from the terminal, and passes the accumulated lines downstream. The final stage prints all four lines with the pid of the process that read each one.
Before typing anything, run ps in another window: all four processes already exist. The shell does not launch stage 2 when stage 1 finishes. It launches the whole pipeline at once, and the final stage is alive and waiting before you have pressed a single key.
That leaves three questions. The processes are all instances of the same binary, so how does each one know whether it is first, last, or somewhere in the middle? After the first stage, stdin carries data from a pipe, so how does a process get back to the keyboard? And since all four are running from the start, what stops them from trying to read your input at once?
How does each process know where it is?
It is easy to picture a pipeline as having one stdin at the beginning and one stdout at the end. But stdin and stdout belong to processes, not pipelines. Every process has its own fd 0 and fd 1. The shell simply connects them to different things.
For the first process, fd 0 still points to the terminal. For every later process, it points to the previous stage’s pipe. At the other end, the final process’s fd 1 points to the terminal while every earlier process writes to a pipe.
Rust exposes the relevant check through IsTerminal:
let lines: Vec<Entry> = if stdin.is_terminal() {
// First stage: read from stdin, which is the terminal.
} else {
// Later stage: drain the pipe, then read from the terminal.
};
if stdout.is_terminal() {
// Last stage: print for the user.
} else {
// Earlier stage: serialize for the next process.
}
The binary does not need a stage number. It can infer its position from what its own stdin and stdout are connected to.
What tells the next stage to start?
I initially expected the stages to need a separate coordination channel. They do not. A downstream stage starts by draining its stdin:
let mut buf = String::new();
stdin.read_to_string(&mut buf)?;
At first, this looks like ordinary data loading. But read_to_string does not return just because the pipe is empty. An empty pipe means there is nothing to read yet; EOF means nothing can ever arrive again.
As long as stage 1 is alive, stage 2 waits inside that call. When stage 1 exits, its end of the pipe closes. Only then does stage 2’s read return. The pipe itself provides the handoff: there is no separate “your turn” message.
But stage 2 now has a different problem. It is finally awake, and fd 0 is an exhausted pipe. It still has no apparent way to reach the keyboard.
How does a piped process get the keyboard back?
After draining stdin, a downstream stage still has the exhausted pipe on fd 0. It opens its controlling terminal separately:
let term = File::open("/dev/tty")?;This does not restore fd 0. It creates a new file descriptor, usually the next free slot, that refers to the same terminal. The program can read from that descriptor directly. There is no ceremony involved: no permission to request, no coordination with the shell. Any process with a controlling terminal can open it at any time.
This is where my mental model had been backwards. Your keystrokes never go “to stdin.” They go to the terminal, and fd 0 is just a descriptor that usually happens to point there. When the shell pointed fd 0 at a pipe instead, the keyboard did not go anywhere; the process only lost its usual pointer to it. Opening /dev/tty makes a new one. This is what less is doing when you press j: the file arrives on stdin, and your keystrokes come from the terminal.
/dev/tty does not name a particular device such as /dev/ttys003. It resolves to the controlling terminal of the calling process. The processes in this pipeline belong to the terminal session created by the shell, so the same path works for every stage.
Here is the central part of the program (full source at the bottom):
let lines: Vec<Entry> = if fd0.is_terminal() {
let mut buf = String::new();
fd0.read_line(&mut buf)?;
vec![Entry { pid: process::id(), data: buf }]
} else {
let mut buf = String::new();
fd0.read_to_string(&mut buf)?;
let mut lines: Vec<Entry> = serde_json::from_str(&buf)?;
let term = File::open("/dev/tty")?;
let mut tty = io::BufReader::new(term);
buf.clear();
tty.read_line(&mut buf)?;
lines.push(Entry { pid: process::id(), data: buf });
lines
};
if fd1.is_terminal() {
for entry in lines {
println!("{}: {}", entry.pid, entry.data);
}
} else {
serde_json::to_writer(fd1, &lines)?;
}
What if two processes read the terminal at once?
They compete. Terminal input is a queue, not a broadcast: whichever read gets there first consumes the line, and it is gone. The terminal does not know the pipeline exists and does not assign turns.
The race never occurs here, but not because anything prevents it. Every downstream process is still blocked on its pipe; the ordering comes from the program’s reads, not from the terminal.
Ctrl-C may look like an exception, but it takes a different path. The terminal driver turns it into SIGINT for the foreground process group. Input goes to one reader; terminal-generated signals go to the group.
Does exec reset stdin and stdout?
If it did, pipelines could not work. The shell first connects pipes to fd 0 and fd 1 in each child, then calls exec to run the requested program.
exec replaces the program’s code and memory, but its open descriptors survive unless they are marked close-on-exec. The new program begins with the shell’s connections already in place.
Is Ctrl-D really EOF?
Not in the same sense as a pipe. A pipe reaches EOF when no write ends remain. A terminal has no equivalent writer count.
In canonical mode, Ctrl-D tells the terminal driver to finish the current read. If its input buffer is empty, that read returns zero bytes. The program observes EOF, but it arrived through a different mechanism.
What changes when the pipeline ends with &?
The first process still has the terminal on fd 0, but its process group is no longer in the foreground. When it tries to read, the terminal driver normally sends the group SIGTTIN and stops it. Otherwise, a background process could consume input intended for the shell.
Running fg registers the job as the foreground process group and sends it SIGCONT. The same read can then continue.
If my terminal app uses a pty, what does /dev/tty open?
/dev/tty is not a separate terminal. It resolves to whichever terminal already controls the calling process. Inside a terminal app, that terminal is one half of a pseudo-terminal pair.
The shell and its children use one half as if it were a hardware terminal. This is called the slave side. The terminal app holds the other half, the master side, where it sends your keystrokes and receives the output to render.
Tools such as SSH and tmux use the same abstraction. An SSH server can allocate a pty for a remote session; tmux keeps the master side of a pty alive when a client detaches. From the program’s point of view, it still has a terminal.
This is also why /dev/tty can fail in a headless process: if the process has no controlling terminal, there is nothing for the path to resolve to.
A practical macOS caveat
Opening /dev/tty was enough for this experiment, but it is not always a drop-in replacement for stdin. Some interactive programs expect the descriptor to be open for both reading and writing. Polling implementations can also treat the /dev/tty alias differently from the concrete pty device on macOS.
In the case that led me here, the interactive program passed isatty() and rendered correctly but did not receive input. Resolving the concrete character device associated with the terminal, such as /dev/ttys003, gave the runtime a device it could monitor with kqueue.
Full source
```
use std::{
error::Error, fs::File, io::{self, BufRead, IsTerminal, Read, Write}, process
};
[derive(serde::Serialize, serde::Deserialize)]
struct Entry {
pid: u32,
data: String,
}
fn err_to_code(err: impl Error) -> process::ExitCode {
eprintln!("{}", err);
process::ExitCode::FAILURE
}
fn main() -> Result<(), process::ExitCode> {
let mut fd0 = std::io::stdin();
let mut fd1 = std::io::stdout();
let lines: Vec = if fd0.is_terminal() {
// this is first input
let mut buf = String::new();
let _ = fd0.read_line(&mut buf).map_err(err_to_code)?;
let pid = std::process::id();
vec![Entry { pid, data: buf }]
} else {
let mut buf = String::new();
let _ = fd0.read_to_string(&mut buf).map_err(err_to_code)?;
let mut lines: Vec = serde_json::from_str(&buf).map_err(err_to_code)?;
// let's get the terminal
let term = File::open("/dev/tty").map_err(err_to_code)?;
let mut tty = io::BufReader::new(term);
buf.clear();
tty.read_line(&mut buf).map_err(err_to_code)?;
let pid = std::process::id();
lines.push(Entry { pid, data: buf });
lines
};
if fd1.is_terminal() {
for i in lines {
fd1.write(format!("{}: {}\n", i.pid, i.data).as_bytes())
.map_err(err_to_code)?;
}
fd1.flush().map_err(err_to_code)?;
} else {
serde_json::to_writer(fd1, &lines).map_err(err_to_code)?;
}
Ok(())
}
``
One question to leave with, going the other way. Putfzfin the middle of a pipeline:ls | fzf | xargs wc -l. Its stdin is a pipe, its stdout is a pipe, andxargs` is already running. The full-screen interface renders anyway.
References
IsTerminal: the Rust trait behind the is-this-a-terminal checks- tty(4):
/dev/ttyand the controlling terminal - pipe(7): pipe semantics, including EOF when no write ends remain
- termios(3): canonical mode, EOF character, and the rest of the terminal driver’s behavior
- pty(7): pseudo-terminal pairs