A terminal case of Linux
👋 This page was last updated ~3 years ago. Just so you know.
Has this ever happened to you?
You want to look at a JSON file in your terminal, so you pipe it into jq so you can look at it with colors and stuff.
Cool bear's hot tip
That's a useless use of cat.
...oh hey cool bear. No warm-up today huh.
Sure, fine, okay, I'll read the darn man page for jq
... okay it takes
a "filter" and then some files. And the filter we want is.. .
which, just
like files, means "the current thing":
There! Now you have pretty colors! But say your JSON file is actually quite large, and it doesn't fit in your terminal window, so you want to use a pager, maybe something like less.
Well, showing a less
invocation is actually quite annoying, so instead we'll
just use cat
instead.
Again?
Yes, bear, again. I know how you feel about cats, in fact you're closer to dogs, but please bea... please let me continue.
Now the pretty colors are all gone!
Of course, we can force jq
to output colors anyway, with -C
(short for
--color-output
):
But something is afoot.
And it's not just jq
! ls
even starts separating items with newlines:
We can "fix" it with --color=always
, but yeah. There's definitely some
detection going on there too.
Also, if we pipe ls --color=always
to less
, we see strange markings!
If we want to see color in less
, we need to use the -R
(short for
--RAW-CONTROL-CHARS
— yes, really).
In fact... let me try something:
AhAH! We can save colors to a file and print them later - and our friend xxd the hex dumper shows us that the colors are really part of the output.
What did we learn?
So far, we know three things. On Linux,
- Colors are part of the output, they're in-band
- Some programs stop outputting color when their output is redirected
- There's usually a way to force them to output color anyway, but it's a per-program setting (there's no standard)
But enough about shells
So far we've been running commands from zsh, because that's the shell I dislike the least right now.
But what if we execute commands from another program? Like, a Rust program?
Let's try doing that:
$ cargo new terminus
Created binary (application) `terminus` package
$ cd terminus/
// in `terminus/src/main.rs`
use std::{error::Error, process::Command};
fn main() -> Result<(), Box<dyn Error>> {
let out = {
Command::new("/bin/ls")
.arg("--color=auto")
.output()
.map(|s| String::from_utf8(s.stdout))??
};
println!("{}", out);
Ok(())
}
What, no fancy crates today?
No! I would normally pull in color-eyre, tracing and tracing-subscriber, but today we're on a diet.
Now, if we run this, we can see...
No colors.
What? I see green.
That's just cargo's output — we could suppress it with --quiet
(or -q
for
short) .
Ahhh, the output of ls doesn't have colors. And it should have like,
blue for src
and target
, given that they're directories.
Exactly!
So! ls
knows its output is being redirected somewhere, and it doesn't print
colors. Even when we execute it from a Rust program.
But how? We could look at the source code for ls
. That would be fun — I've
never done that!
Luckily, there's a GitHub mirror for coreutils, so it's not too hard to find.
In ls.c
, in the the decode_switches
function, we can find a switch inside
of a while(true)
loop, that seems to process command-line arguments.
Here for example, it enables "human-friendly output":
// in `coreutils/src/ls.c`
case 'h':
file_human_output_opts = human_output_opts =
human_autoscale | human_SI | human_base_1024;
file_output_block_size = output_block_size = 1;
break;
And.. here's the --color
switch:
// in `coreutils/src/ls.c`
case COLOR_OPTION:
{
int i;
if (optarg)
i = XARGMATCH ("--color", optarg, when_args, when_types);
else
/* Using --color with no argument is equivalent to using
--color=always. */
i = when_always;
print_with_color = (i == when_always
|| (i == when_if_tty && stdout_isatty ()));
break;
}
Ah! print_with_color
is set to a truthy value if:
--color=always
is passed (we knew that), or--color=auto
is passed andstdout_isatty()
returns true
And here's the code for stdout_isatty()
:
// in `coreutils/src/ls.c`
/* Return true if standard output is a tty, caching the result. */
static bool
stdout_isatty (void)
{
static signed char out_tty = -1;
if (out_tty < 0)
out_tty = isatty (STDOUT_FILENO);
assume (out_tty == 0 || out_tty == 1);
return out_tty;
}
Cool bear's hot tip
Look at that, it's even doing memoization!
Initially, the value of out_tty
is -1, but it persists across function calls
(because it's static
), so after the first call returns, it'll be 0
or 1
,
depending what isatty
returns.
Mhh, isatty
, we can probably call that from Rust, right?
Feels like a libc
function... ah yep here's a man page:
DESCRIPTION
These functions operate on file descriptors for terminal type devices.
The
isatty()
function determines if the file descriptor fd refers to a valid terminal type device.The
ttyname()
function gets the related device name of a file descriptor for which isatty() is true.The
ttyname()
function returns the name stored in a static buffer which will be overwritten on subsequent calls. Thettyname_r()
function takes a buffer and length as arguments to avoid this problem.
Okay okay, how do we call libc functions... uhh well we know the Rust standard library relies on libc for a bunch of things, so surely we already link against it...
$ ldd ./target/debug/terminus
linux-vdso.so.1 (0x00007ffd7cf69000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f4e15280000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f4e1525d000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f4e15257000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f4e15065000)
/lib64/ld-linux-x86-64.so.2 (0x00007f4e15302000)
Yes we do! That's libc.so.6
in the list, which should have isatty
...
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep -E 'isatty|ttyname'
00000000001324d0 T __ttyname_r_chk
0000000000112cc0 W isatty
0000000000112580 T ttyname
0000000000112900 W ttyname_r
Yes! Although, uh, isatty
is a weak symbol.
Anyway. If we define isatty
in an extern "C"
block, we should be able to
call it.
Call it but... with what?
Well it takes an int fd
, which is a file descriptor. Typically, we have:
- 0 for stdin (standard input)
- 1 for stdout (standard output)
- 2 for stderr (standard error)
Ah, we can try 1
then!
// in `terminus/src/main.rs`
use std::{error::Error, os::raw::c_int};
extern "C" {
fn isatty(fd: c_int) -> c_int;
}
const STDOUT: c_int = 1;
fn main() -> Result<(), Box<dyn Error>> {
let stdout_is_tty = unsafe { isatty(STDOUT) } == 1;
dbg!(stdout_is_tty);
Ok(())
}
$ cargo build --quiet
$ ./target/debug/terminus
[src/main.rs:11] stdout_is_tty = true
$ ./target/debug/terminus | cat
[src/main.rs:11] stdout_is_tty = false
So that's how they do it!
Okay so there's two ways we can go from here: we can go lower-level, or we can go higher-level.
A little assembly, as a treat
Let's first dig down: how does isatty even work. We give it a file descriptor, literally just "the integer value one", and it tells us if it's a terminal or not.
But libc is just, you know, a big flaming ball of C code. There's no reason for it to have any extra powers — there's programs that don't use libc at all, and they're still able to tell whether or not a file descriptor is a terminal.
Which is to say, libc is not in charge of file descriptors. The kernel is.
And we know one way userland applications (like ours) can talk to the Linux kernel is by performing a syscall.
So... is it making a syscall? Let's ask our friend strace the... ostrich? (No, for real, click that link).
We'll change our main
function to look like this:
// in `terminus/src/main.rs`
fn main() -> Result<(), Box<dyn Error>> {
println!("calling isatty...");
let stdout_is_tty = unsafe { isatty(STDOUT) } == 1;
println!("calling isatty... done!");
dbg!(stdout_is_tty);
Ok(())
}
...just so it's easier to pinpoint the moment we call isatty
:
$ cargo build --quiet
$ strace -o /tmp/strace.log -- ./target/debug/terminus
calling isatty...
calling isatty... done!
[src/main.rs:13] stdout_is_tty = true
$ cat /tmp/strace.log | grep 'calling isatty... done' -B 2
write(1, "calling isatty...\n", 18) = 18
ioctl(1, TCGETS, {B38400 opost isig icanon echo ...}) = 0
write(1, "calling isatty... done!\n", 24) = 24
That's another useless use of cat!
AhhhHHHHH FINE
$ grep 'calling isatty... done' -B 2 /tmp/strace.log
write(1, "calling isatty...\n", 18) = 18
ioctl(1, TCGETS, {B38400 opost isig icanon echo ...}) = 0
write(1, "calling isatty... done!\n", 24) = 24
The original output is a lot so I used a well-placed grep
to whittle it down
to something reasonable.
Okay, so it is making a syscall. Our println!
and dbg!
macros end up being
write
syscalls, which take three arguments: the file descriptor, the data, and
the length.
Oooh it's writing to file descriptor 1! That's the standard output!
Correct!
And it's doing a syscall to ioctl
! Also passing file descriptor 1.
Which returns... 0. That's kernel for great success!
However, if we redirect terminus
's output:
$ strace -o /tmp/strace.log -- ./target/debug/terminus > /dev/null
[src/main.rs:13] stdout_is_tty = false
$ grep 'calling isatty... done' -B 2 /tmp/strace.log
write(1, "calling isatty...\n", 18) = 18
ioctl(1, TCGETS, 0x7ffe0d358c30) = -1 ENOTTY (Inappropriate ioctl for device)
write(1, "calling isatty... done!\n", 24) = 24
Then it returns -1
, which strace helpfully translates to ENOTTY
, ie. "error
not a TTY".
So that means... we can make that syscall ourselves!
Yeah! Who needs libc? Not us!
We will, however, need the unstable asm feature.
No problem! We can switch to the nightly channel:
# in `terminus/rust-toolchain.toml`
[toolchain]
channel = "nightly-2021-09-23"
If you're using rustup to manage Rust versions, now, all
cargo commands in the terminus/
folder should use that nightly version. Neat!
Cool bear's hot tip
Why pin to a specific nightly instead of just doing channel = "nightly"
(which
would install the latest nightly)?
Well, in nightly Rust anything can change. And in case you're reading this article from The Future, it's very possible that your nightly will behave differently.
So, for this article, we're using that specific nightly. Upgrade at your own risk.
// in `terminus/src/main.rs`
#![feature(asm)]
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
// on Linux x86_64, everything is an `u64`.
const STDOUT: u64 = 1;
// found in linux/source/include/uapi/asm-generic/ioctls.h
const TCGETS: u64 = 0x5401;
// let's go ahead and guess that whatever "TCGETS" is getting
// doesn't take more than 32KiB.
const GENEROUS_BUFFER_SIZE: usize = 32 * 1024;
let mut mysterious_buffer = vec![0u8; GENEROUS_BUFFER_SIZE];
// okay, here we gooo
let ret = unsafe { ioctl(STDOUT, TCGETS, mysterious_buffer.as_mut_ptr()) };
// phew, we made it.
dbg!(ret);
Ok(())
}
unsafe fn ioctl(fd: u64, cmd: u64, arg: *mut u8) -> i64 {
let syscall_number: u64 = 16;
let ret: u64;
asm!(
"syscall",
inout("rax") syscall_number => ret,
in("rdi") fd,
in("rsi") cmd,
in("rdx") arg,
// those aren't used, but they may be clobbered by
// the syscall, so we need to let LLVM know.
lateout("rcx") _, lateout("r11") _,
options(nostack)
);
// errors are negative, so this is actually an i64
ret as i64
}
Let's give it a shot!
$ cargo run --quiet
[src/main.rs:21] ret = 0
$ cargo run --quiet | cat
[src/main.rs:21] ret = -25
And error 25
is...
// in `linux/source/include/uapi/asm-generic/errno-base.h`
#define ENOTTY 25 /* Not a typewriter */
Hurray!
HECK YEAH NO LIBC
Well I mean... we still depend on, like...
$ nm ./target/debug/terminus | grep "U " | grep GLIBC | wc -l
56
...at least fifty-six functions from libc.
Well okay sure but we made our own isatty
! Go us!
And thus concludes our ride down into the lower level of abstractions.
Now we know!
What did we learn?
If you really, really don't want to use libc, you don't have to.
Linux kernel syscall numbers are stable (here's a nice table of them) and so are the constants, so you don't have to go through libc.
The situation is different on other mainstream OSes. For example, Go used to do "raw system calls" on macOS, but Go programs often broke with new kernel versions. As of Go 1.16, they've switched to libc.
Moving on up
Let's return to a place where we don't actually need to make our own syscalls, shall we? There's a couple others I'd like to try out.
So let's switch back to stable:
$ rm rust-toolchain.toml
And then just use the libc
crate!
$ cargo add libc
Updating 'https://github.com/rust-lang/crates.io-index' index
Adding libc v0.2.102 to dependencies
// in `terminus/src/main.rs`
use libc::{isatty, STDOUT_FILENO};
fn main() {
let stdout_is_tty = unsafe { isatty(STDOUT_FILENO) };
dbg!(stdout_is_tty);
}
$ cargo run -q
[src/main.rs:5] stdout_is_tty = 1
$ cargo run -q | cat
[src/main.rs:5] stdout_is_tty = 0
There, street cred be damned.
Since we're on Linux, it's mountains of C all the way down anyway.
For now... for now.
Now, where were we? Ah right! The man page for isatty
also mentioned
ttyname
, which looked interesting, because, between you and me, I still don't
have the faintest idea what a "TTY" actually is.
Fair enough, let's try it.
// in `terminus/src/main.rs`
use libc::{isatty, ttyname, STDOUT_FILENO};
use std::{error::Error, ffi::CStr};
fn main() -> Result<(), Box<dyn Error>> {
let stdout_is_tty = unsafe { isatty(STDOUT_FILENO) } == 1;
if stdout_is_tty {
let tty_name = unsafe { ttyname(STDOUT_FILENO) };
assert!(!tty_name.is_null());
let tty_name = unsafe { CStr::from_ptr(tty_name) };
let tty_name = tty_name.to_str()?;
println!("stdout is a TTY: {}", tty_name);
} else {
println!("stdout is not a TTY");
}
Ok(())
}
$ cargo run -q
stdout is a TTY: /dev/pts/11
$ cargo run -q | cat
stdout is not a TTY
Ahah! A TTY is... a file?
What can we do with that file? Can we write to it?
Oh my. Yes. Yes we can.
(I didn't type in the upper pane — it was printed when I ran echo in the lower pane.)
Can we read from it?
Uhhhhhhh sorta kinda yes. It looks like it's a free-for-all: whoever reads first gets the.. worm. If I type really slowly "cat" wins the race.
Wait, you're doing all of this from inside tmux right?
That's how you can have several panes like that?
Yeah!
So does that mean... does each pane have its own TTY?
That seems like it makes sense, but let's check:
Right! They certainly each have their own terminals. How many terminals am I
even on right now? Assuming they pop in and out of /dev/pts
, we should be
able to just list them like so:
$ ls -lhA /dev/pts
total 0
crw--w---- 1 root tty 136, 0 Sep 23 11:57 0
crw--w---- 1 amos tty 136, 1 Sep 23 11:57 1
crw--w---- 1 amos tty 136, 10 Sep 24 18:23 10
crw--w---- 1 amos tty 136, 11 Sep 24 18:23 11
crw--w---- 1 amos tty 136, 12 Sep 24 18:15 12
crw--w---- 1 amos tty 136, 13 Sep 24 18:23 13
crw--w---- 1 amos tty 136, 2 Sep 24 18:23 2
crw--w---- 1 amos tty 136, 3 Sep 24 12:46 3
crw--w---- 1 amos tty 136, 4 Sep 24 18:22 4
crw--w---- 1 amos tty 136, 5 Sep 24 13:09 5
crw--w---- 1 amos tty 136, 6 Sep 24 12:50 6
crw--w---- 1 amos tty 136, 7 Sep 24 12:47 7
crw--w---- 1 amos tty 136, 8 Sep 24 13:00 8
crw--w---- 1 amos tty 136, 9 Sep 24 18:22 9
c--------- 1 root root 5, 2 Sep 23 11:57 ptmx
Ah! Fourteen (counting from zero). Some even date from yesterday. How do we know who they belong to?
Well... they're just files, right?
Right.. so if some process has them open... we should have corresponding file descriptors.
Oooh and the kernel keeps track of file descriptors!
Right! And lsof
(list open files) can let us know which process are holding
which file descriptors.
Let's try it for the current TTY:
$ cargo run -q
stdout is a TTY: /dev/pts/13
$ lsof /dev/pts/13
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
zsh 10614 amos 0u CHR 136,13 0t0 16 /dev/pts/13
zsh 10614 amos 1u CHR 136,13 0t0 16 /dev/pts/13
zsh 10614 amos 2u CHR 136,13 0t0 16 /dev/pts/13
zsh 10614 amos 10u CHR 136,13 0t0 16 /dev/pts/13
zsh 10661 amos 15u CHR 136,13 0t0 16 /dev/pts/13
zsh 10661 amos 16u CHR 136,13 0t0 16 /dev/pts/13
zsh 10661 amos 17u CHR 136,13 0t0 16 /dev/pts/13
zsh 10667 amos 15u CHR 136,13 0t0 16 /dev/pts/13
zsh 10667 amos 16u CHR 136,13 0t0 16 /dev/pts/13
zsh 10667 amos 17u CHR 136,13 0t0 16 /dev/pts/13
zsh 10669 amos 15u CHR 136,13 0t0 16 /dev/pts/13
zsh 10669 amos 16u CHR 136,13 0t0 16 /dev/pts/13
zsh 10669 amos 17u CHR 136,13 0t0 16 /dev/pts/13
gitstatus 10670 amos 15u CHR 136,13 0t0 16 /dev/pts/13
gitstatus 10670 amos 16u CHR 136,13 0t0 16 /dev/pts/13
gitstatus 10670 amos 17u CHR 136,13 0t0 16 /dev/pts/13
lsof 14867 amos 0u CHR 136,13 0t0 16 /dev/pts/13
lsof 14867 amos 1u CHR 136,13 0t0 16 /dev/pts/13
lsof 14867 amos 2u CHR 136,13 0t0 16 /dev/pts/13
Ha! That's a lot of processes. I guess they all just coordinate together to make that fancy prompt happen. (It's powerlevel10k, by the way).
Oh and lsof
even found itself!
Mhh I wonder how lsof
works... is it using a fancy kernel interface?
Let's find out!
$ strace -o strace.log -- lsof /dev/pts/10 &> /dev/null
$ grep -E 'openat.*/proc' strace.log | head
openat(AT_FDCWD, "/proc/filesystems", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/proc/mounts", O_RDONLY) = 3
openat(AT_FDCWD, "/proc/16057/fdinfo/3", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/locks", O_RDONLY) = 3
openat(AT_FDCWD, "/proc", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = 3
openat(AT_FDCWD, "/proc/1/stat", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/1/maps", O_RDONLY) = -1 EACCES (Permission denied)
openat(AT_FDCWD, "/proc/1/fd", O_RDONLY|O_NONBLOCK|O_CLOEXEC|O_DIRECTORY) = -1 EACCES (Permission denied)
openat(AT_FDCWD, "/proc/45/stat", O_RDONLY) = 6
openat(AT_FDCWD, "/proc/45/maps", O_RDONLY) = -1 EACCES (Permission denied)
Oh. Uh oh.
$ grep -E 'openat.*/proc' strace.log | wc -l
1267
Oh. Ooooooh okay. It's just going through procfs, looking for all open file descriptors of all processes.
Well then.
What did we learn?
At least inside of tmux, our TTYs (terminals, née teletype) are PTYs (pseudo-terminals).
Pseudo-terminals live under /dev/pts/
and apparently, anyone can just go ahead
and open them. I mean, there's Unix permissions and stuff but still, uh, feels
a little unsettling.
Something doesn't add up
Okay. OKAY. That's all well and good. I feel like we're making tangible progress here, but there's something that puzzles me.
Yeah! Something doesn't add up!
You think so too? What thing, exactly?
Well the... we've got one end of the.. when we open a pseudo-terminal, what we have is... and we can write into it... but then how-
So you don't know. You just thought you'd jump in.
I...
Aaaaaanyway.
Yes, bear. Yes, we're only holding half of it.
Because as you may have noticed, when we rudely echoed something into
/dev/pts/N
, it showed up in the other pane, but zsh didn't try to execute it.
Yes, that! Why didn't it try to execute it?
Well, let's think for a second.
When we type into a terminal, who gets the input?
Well... in this case it's zsh?
Wrong! It absolutely is not zsh. First, it's my Logitech K120 keyboard, which I keep trying to replace but is the only thing that has worked for me for the past decade, that's right mechanical keyboard nerds, your resident deep dive writer is using second-rate office supplies.
And then, well, something something USB-A, a hub, USB-C, presumably some Intel piece of hardware inside my MacBookPro, the driver for the USB host controller, eventually the Windows kernel, and then... the input chain? Who knows how many layers that's got — but eventually we get a "key event", whatever form it takes.
Then it goes through Windows
Terminal which, honestly if
you're still using cmd.exe
well — what are you waiting to switch over?
Of course that's because I have a weird WSL2 setup going on. I tried using Linux as my main OS, I really did.
But my point is: it's not zsh
that gets the input. It's Windows Terminal. If I
was on Linux, it'd be alacritty or
xfce4-terminal
or something.
Mhhh. Okay, fair, but surely it then just forwards that input to zsh, right?
Wrong! Because that would not be a TTY. If zsh's standard input was merely a
pipe that Windows Terminal wrote to (let's forget there's two different OSes
in action for a minute), isatty
would definitely return false.
And besides... watch this:
And now this:
Yeah, the output of bat scales depending on the size of the terminal, so what?
Well, how do you do that with a pipe, huh bear? HOW INDEED?
Ohhhhhhhhh.. with... with...
THAT'S RIGHT. With an ioctl
syscall. Which we did above.
But the ioctls we want to perform don't work unless the file descriptor is a TTY.
So then...
Then we're back to square one, yes.
And you know what's worse? All that information, like the terminal's size and whatnot, it's out-of-band!
It is?
Yeah! It's not like colors which are like "ooh here's a funny byte and then ASCII numbers and punctuation I guess, 420 shipit" — these are kernel operations on file descriptors, they're not in the stream at all.
But then how do you know wh-
When a terminal is resized? Well HERE COMES THE FUN BIT! It's not via ioctls! Not at all! Because those are a "pull" interface, you just ask for the info when you need it — and you don't want to be constantly requesting it.
(It's presumably "costly", otherwise ls
wouldn't memoize/cache it. At the very
least it crosses the kernel boundary, because it's a syscall).
So you know how you know? That a terminal is resized?
No, that's what I was just ask-
WELL YOU GET A SIGNAL
THAT'S RIGHT. A SIGNAL. Just like when a process is killed or stopped.
So between in-band information (like ANSI escape codes) and out-of-band information (like signals and ioctls), GOOD LUCK WRITING A TERMINAL EMULATOR.
"EVERYTHING IS A FILE" MY LILY-WHITE BUTT.
Amos please, there's childr-
WELL THEY HAD TO FIND OUT ONE DAY.
Getting past denial and anger
Okay, okay, let's take a breather. We actually don't need to care about signals, thankfully. We'll just never ever resize our terminals. That's fine. It's fine. It's okay. We're okay.
I don't really think we need to care about ioctls either, if all we want to do is just pretend we're a terminal - we don't need to get the terminal info, our child process does... just like zsh requests the terminal info set by Windows Terminal.
But don't we need to... set it?
Bear, I don't even know how we make a pseudo-terminal.
No don't... don't cr- here, I'm looking it up right now. If we look for libc functions that have "pty" in them, surely we'll find something...
quietly sobbing
THERE! There's a thing called "openpty".
There... there is?
Yeahhhh! It looks great! It takes a pointer to a master file descriptor, and to a, oh.
What?
Nothing haha. I was saying: it takes a pointer to a primary file descriptor, and a secondary file descriptor, also a name, a "termp" and a "winp".
Please tell me most of these can be null.
looks closer yeah I mean we can try.
Okay. Sure. We'll try.
Let's look at that function closer... gonna be paraphrasing that man page to modernize it a little bit.
The function
openpty()
attempts to obtain the next available pseudo- terminal from the system (seepty(4)
).
Okay...
If it successfully finds one, it subsequently changes the ownership of the secondary device to the real UID of the current process, the group membership to the group "tty" (if such a group exists in the system), the access permissions for reading and writing by the owner, and for writing by the group, and invalidates any current use of the line by calling revoke(2).
Okay, sure, permissions, why not.
If the argument
name
is not NULL,openpty()
copies the pathname of the secondary pty to this area. The caller is responsible for allocating the required space in this array.
Oh HELL no, we're not letting libc write past the end of a buffer. Not today. That'll be null, thank you very much.
If the arguments termp or winp are not NULL, openpty() initializes the termios and window size settings from the structures these arguments point to, respectively.
Ah! Those can be null.
Upon return, the open file descriptors for the primary and secondary side of the pty are returned in the locations pointed to by aprimary and asecondary, respectively.
Very well, let's see what happens then.
// in `terminus/src/main.rs`
use libc::{isatty, ttyname};
use std::{error::Error, ffi::CStr};
fn main() -> Result<(), Box<dyn Error>> {
let mut primary_fd: i32 = 0;
let mut secondary_fd: i32 = 0;
println!("Opening pty...");
unsafe {
let ret = libc::openpty(
&mut primary_fd,
&mut secondary_fd,
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null(),
);
if ret != 0 {
panic!("Failed to openpty!");
}
};
dbg!(primary_fd, secondary_fd);
let is_tty = unsafe { isatty(secondary_fd) } == 1;
if is_tty {
let tty_name = unsafe { ttyname(secondary_fd) };
assert!(!tty_name.is_null());
let tty_name = unsafe { CStr::from_ptr(tty_name) };
let tty_name = tty_name.to_str()?;
println!("secondary is a TTY: {}", tty_name);
} else {
println!("secondary is not a TTY");
}
Ok(())
}
$ cargo run --quiet
Opening pty...
[src/main.rs:20] primary_fd = 3
[src/main.rs:20] secondary_fd = 4
secondary is a TTY: /dev/pts/9
Yes.
YES.
WE HAVE A PSEUDO-TERMINAL. AHHHHH
And I guess we hold the primary and the secondary is what our child process gets?
I guess!?!
Ok but how-
Yes yes how do we get another process to use said pseudo-terminal. Well, you'd think you could just pass the file descriptor as stdin/stdout/stderr, right?
Wrong!
Because see, Linux processes have, like, "sessions", and those sessions have "leaders" and also they have a "controlling terminal" and there's definitely a call to "allocate a new session" but the way you change the "controlling terminal" is much much funnier.
Let's look at the code for login_tty
, which does both:
// in `glibc/login/login_tty.c`
int
__login_tty (int fd)
{
__setsid();
#ifdef TIOCSCTTY
if (__ioctl(fd, TIOCSCTTY, NULL) == -1)
return (-1);
#else
{
/* This might work. */
char *fdname = ttyname (fd);
int newfd;
if (fdname)
{
if (fd != 0)
_close (0);
if (fd != 1)
__close (1);
if (fd != 2)
__close (2);
newfd = __open64 (fdname, O_RDWR);
__close (newfd);
}
}
#endif
while (__dup2(fd, 0) == -1 && errno == EBUSY)
;
while (__dup2(fd, 1) == -1 && errno == EBUSY)
;
while (__dup2(fd, 2) == -1 && errno == EBUSY)
;
if (fd > 2)
__close(fd);
return (0);
}
I... I don't know where to start. On platforms that don't support the
TIOCSCTTY
ioctl, it closes standard input, output and error and opens
the secondary side of the TTY.
Because that makes it the controlling terminal. Because of course.
And then uhh those while
loops look horrifying to me but I guess they're
extremely standard *nix stuff and I'm really just showing my lack of experience
there.
Regardless: yuck.
But you know... as long as it works...
The real question is... when do we do that? When do we call login_tty
?
Cool bear's hot tip
If we didn't insist on using Rust's
Command
abstraction, we could just use forkpty, which
does fork
and login_tty
.
But we do insist. And it's more fun to peek into what's "actually" going on, for some value of "actually".
When we executed a program from Rust, we did something like this:
Command::new("/bin/ls")
.arg("--color=auto")
.output()
.map(|s| String::from_utf8(s.stdout))??
But that nice, high-level API is hiding the terrible truth.
A great number of cursed things happen when spawning a process on Linux.
Well, unless you use posix_spawn. That's the good one.
But the traditional way, which we'll have no choice but to use here, has two important steps.
First, we fork. This creates an "exact" copy of the calling process, except for like, twenty different things (like having a different PID (process ID), a different PPID (parent process ID), etc.)
What's fun about fork is that we're splitting the space-time continuum — technically, it returns twice. Once in the parent process, and once in the child process.
In the child process, it returns 0
, and that's how you know it's the child
process because, well, since it's an "exact" copy, they're still executing the
same code at this point. So you compare against 0
, and things diverge from
there.
And usually, at that point, you want to move on to exec
, which asks the kernel
to violently replace the current process (the child process we just fashioned
from the ribs of Adam the memory space of the calling process) with
whichever ELF file we said to use.
Which is the way you normally run programs. Unless you really have too much time on your hands.
So let's think! We have login_tty
, which makes the calling process the leader
of a new session, and sets its controlling terminal to whichever file descriptor
we passed.
Well. If we call it before fork
it's going to mess with our parent process,
which is almost definitely not what we want. We don't want to become the leader
of our session. We don't want to change our own controlling terminal.
We only want, say, ls
to have as controlling terminal, the pseudo-terminal we
just created (with openpty
).
If we call it after exec
, well... well we can't call anything after exec
.
Any code from the parent process, that was "copied into" (really, just mapped)
the child process, stops existing the second we call exec
.
exec
never returns, much like honey badger don't
care.
So really, that only leaves us one choice.. we must execute login_tty
between
fork
and exec
.
Which means we can't use posix_spawn
.
So we'll just look into our Rust code where we can do that and...
Command::new("/bin/ls")
.arg("--color=auto")
.output()
.map(|s| String::from_utf8(s.stdout))??
Mhhh.
Mhh?
I don't see fork
. Or exec
. Or posix_spawn
.
Ahhh yes, they're well hidden, in Terrible Truth Land, also known as the Rust standard library.
See, there's a posix_spawn
fn there, and...
// in `library/std/src/sys/unix/process_unix.rs`
fn posix_spawn(
&mut self,
stdio: &ChildPipes,
envp: Option<&CStringArray>,
) -> io::Result<Option<Process>> {
use crate::mem::MaybeUninit;
use crate::sys::{self, cvt_nz};
if self.get_gid().is_some()
|| self.get_uid().is_some()
|| (self.env_saw_path() && !self.program_is_path())
|| !self.get_closures().is_empty()
|| self.get_groups().is_some()
|| self.get_create_pidfd()
{
return Ok(None);
}
// ...
}
...and the first thing it does is check if we're doing something funky! And if we
are, it doesn't actually use posix_spawn
.
Funky things like, I don't know, setting a different UID (user ID) or GID (group
ID), or... hey, what's that about closures
?
furiously going through std So if that's set here then.. THERE! I got it!
What? Command::pre_exec
? Good job bear! Now we can fina-
Wait, it's unsafe. Why is it unsafe.
It's uns- ah. Yes. Well, let's review the docs.
Notes and Safety
This closure will be run in the context of the child process after a
fork
. This primarily means that any modifications made to memory on behalf of this closure will not be visible to the parent process. This is often a very constrained environment where normal operations likemalloc
, accessing environment variables throughstd::env
or acquiring a mutex are not guaranteed to work (due to other threads perhaps still running when the fork was run).
Ooooh boy. Okay so we won't allocate anyth-
This also means that all resources such as file descriptors and memory-mapped regions got duplicated. It is your responsibility to make sure that the closure does not violate library invariants by making invalid use of these duplicates.
Ah uh
Panicking in the closure is safe only if all the format arguments for the panic message can be safely formatted; this is because although
Command
callsstd::panic::always_abort
before calling the pre_exec hook, panic will still try to format the panic message.
Well I-
When this closure is run, aspects such as the stdio file descriptors and working directory have successfully been changed, so output to these locations may not appear where intended.
Okay, okay I got it — stuff gets real weird in there.
We'll just login_tty
and get out of here as fast as we can.
Leeeeeeeeeeeet's go:
// in `terminus/src/main.rs`
use std::{error::Error, os::unix::prelude::CommandExt, process::Command};
fn openpty() -> (i32, i32) {
let mut primary_fd: i32 = -1;
let mut secondary_fd: i32 = -1;
unsafe {
let ret = libc::openpty(
&mut primary_fd,
&mut secondary_fd,
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null(),
);
if ret != 0 {
panic!("Failed to openpty!");
}
};
(primary_fd, secondary_fd)
}
fn main() -> Result<(), Box<dyn Error>> {
let (primary_fd, secondary_fd) = openpty();
dbg!(primary_fd, secondary_fd);
let mut cmd = Command::new("/bin/ls");
cmd.arg("--color=auto");
unsafe {
cmd.pre_exec(move || {
if libc::login_tty(secondary_fd) != 0 {
panic!("couldn't set the controlling terminal or something");
}
Ok(())
})
};
let output = cmd.output().map(|out| String::from_utf8(out.stdout))??;
println!("{}", output);
Ok(())
}
$ cargo run --quiet
Opening pty...
[src/main.rs:19] primary_fd = 3
[src/main.rs:19] secondary_fd = 4
Oh that's.
That's perhaps a little too quiet.
What the heck happened here...
Wait wait wait, we're calling output()
.
So?
How do you think output()
works?
Well... it probably has to redirect stdout and stderr to pip-
To pipes, yes precisely. And then it runs the pre-exec closures,
one of which calls login_tty
, which..
Oh yeahhh! That would definitely override whatever output()
is doing.
But then.. then how do we get the output from /bin/ls
?
If it's holding the secondary... and we're holding the primary... we need to.. read from it? Potentially maybe?
Okay fine let's try:
use std::{
error::Error,
fs::File,
io::Read,
os::unix::prelude::{CommandExt, FromRawFd},
process::Command,
};
// omitted: fn openpty()
fn main() -> Result<(), Box<dyn Error>> {
let (primary_fd, secondary_fd) = openpty();
dbg!(primary_fd, secondary_fd);
let mut cmd = Command::new("/bin/ls");
cmd.arg("--color=auto");
unsafe {
cmd.pre_exec(move || {
if libc::login_tty(secondary_fd) != 0 {
panic!("couldn't set the controlling terminal or something");
}
Ok(())
})
};
let mut child = cmd.spawn()?;
println!("Opening primary...");
let mut primary = unsafe { File::from_raw_fd(primary_fd) };
println!("Reading from primary...");
let mut buffer = String::new();
primary.read_to_string(&mut buffer)?;
println!("All done!");
println!("{}", buffer);
child.wait()?;
Ok(())
}
$ cargo run --quiet
[src/main.rs:29] primary_fd = 3
[src/main.rs:29] secondary_fd = 4
Opening primary...
Reading from primary...
^C
Okay that just gets stuck forever. Mh.
Uhhh maybe the terminal... remains open?? What does strace
say?
$ cargo build --quiet
$ strace ./target/debug/terminus
(cut)
write(1, "Opening primary...\n", 19Opening primary...
) = 19
write(1, "Reading from primary...\n", 24Reading from primary...
) = 24
read(3, "Cargo.lock Cargo.toml \33[0m\33[01", 32) = 32
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=31500, si_uid=1000, si_status=0, si_utime=0, si_stime=0} ---
read(3, ";34msrc\33[0m strace.log \33[01;34", 32) = 32
read(3, "mtarget\33[0m\r\n", 64) = 13
read(3, ^C0x56265b70c23d, 51) = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
strace: Process 31499 detached
Ohhh. OHHH. Yeah! It does! And read_to_string
reads until EOF, which never
happens.
Cool bear's hot tip
As noted by many, openpty
returns an open file descriptor to the primary and
the secondary. We passed a copy of the secondary to the child process but didn't
close the parent process's copy of it.
If we did close the parent process's copy, then we would see EOF from the primary as soon as the child process exits, and everything past this point in this article would be unnecessary.
However, amos has a use case where he wants to re-use the terminal for spawning several processes in sequence, yet needs to tell apart the output from each individual process, so it makes sense for him. In most other cases though, you can just close the parent's secondary fd and be done with it!
So we need to...
..read from the primary only until the child process exits?
Yeah, that!
fn main() -> Result<(), Box<dyn Error>> {
let (primary_fd, secondary_fd) = openpty();
dbg!(primary_fd, secondary_fd);
let mut cmd = Command::new("/bin/ls");
cmd.arg("--color=auto");
unsafe {
cmd.pre_exec(move || {
if libc::login_tty(secondary_fd) != 0 {
panic!("couldn't set the controlling terminal or something");
}
Ok(())
})
};
let mut child = cmd.spawn()?;
let mut primary = unsafe { File::from_raw_fd(primary_fd) };
let mut out = vec![];
let mut buffer = vec![0u8; 1024];
loop {
let n = primary.read(&mut buffer)?;
out.extend_from_slice(&buffer[..n]);
println!("Read {} bytes...", n);
if child.try_wait()?.is_some() {
break;
}
}
println!("Child exited!");
println!("{}", String::from_utf8(out)?);
Ok(())
}
And, lo and behold:
$ cargo run --quiet
[src/main.rs:29] primary_fd = 3
[src/main.rs:29] secondary_fd = 4
Read 77 bytes...
Child exited!
Cargo.lock Cargo.toml src strace.log target
We have colors.
Uhh ahem.
Oh! Of course.
And lo and behold, we have colors:
We also have: a race condition.
If the child exits between our call to try_wait
and the next read
, we'll be
stuck forever trying to read.
How could we possibly resolve this?
Oh good, channels.
Okay so, it won't be pretty, but it won't race.
Those both sound like negatives.
We'll even run something fancier than ls
, like, cargo check
three times in a
row, sleeping 1 second between each.
// in `terminus/src/main.rs`
fn main() -> Result<(), Box<dyn Error>> {
let (primary_fd, secondary_fd) = openpty();
dbg!(primary_fd, secondary_fd);
let mut cmd = Command::new("/bin/bash");
cmd.arg("-c")
.arg("for i in $(seq 1 3); do cargo check; sleep 1; done");
unsafe {
cmd.pre_exec(move || {
if libc::login_tty(secondary_fd) != 0 {
panic!("couldn't set the controlling terminal or something");
}
Ok(())
})
};
let mut child = cmd.spawn()?;
enum Msg {
Output(Vec<u8>),
Exit,
}
let (tx, rx) = std::sync::mpsc::channel();
let read_tx = tx.clone();
std::thread::spawn(move || {
let mut primary = unsafe { File::from_raw_fd(primary_fd) };
let mut buffer = vec![0u8; 1024];
loop {
let n = primary.read(&mut buffer).unwrap();
println!("Read {} bytes...", n);
let slice = &buffer[..n];
read_tx.send(Msg::Output(slice.to_vec())).unwrap();
}
});
std::thread::spawn(move || {
child.wait().unwrap();
tx.send(Msg::Exit).unwrap();
});
let mut out = vec![];
loop {
let msg = rx.recv()?;
match msg {
Msg::Output(buffer) => out.extend_from_slice(&buffer[..]),
Msg::Exit => break,
}
}
println!("Child exited!");
println!("{}", String::from_utf8(out)?);
Ok(())
}
Mhhhh. If only there was some Rust feature... that lets you deal with problems like these...
What problems?
You know. Concurrency.
Oh dear. You really think we should?
Only one way to find out...
Don't Make Me Wait
What time is it? It's tokio time!
Channels be damned, we're bringing a whole darn executor with us. Because what
is an executor? A miserable pile of secrets interests and tasks.
And we're interested in knowing when the child exits, and also when we can read from our pseudo-terminal primary.
Do you know what you're doing?
Almost never.
So, uhhh let's go:
$ cargo add tokio@1.12.0 --features full
Updating 'https://github.com/rust-lang/crates.io-index' index
Adding tokio v1.12.0 to dependencies with features: ["full"]
// in `terminus/src/main.rs`
use std::{error::Error, os::unix::prelude::FromRawFd};
use tokio::{fs::File, io::AsyncReadExt, process::Command};
fn openpty() -> (i32, i32) {
let mut primary_fd: i32 = -1;
let mut secondary_fd: i32 = -1;
unsafe {
let ret = libc::openpty(
&mut primary_fd,
&mut secondary_fd,
std::ptr::null_mut(),
std::ptr::null(),
std::ptr::null(),
);
if ret != 0 {
panic!("Failed to openpty!");
}
};
(primary_fd, secondary_fd)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let (primary_fd, secondary_fd) = openpty();
dbg!(primary_fd, secondary_fd);
let mut cmd = Command::new("/bin/bash");
cmd.arg("-c")
.arg("for i in $(seq 1 3); do cargo check; sleep 0.2; done");
unsafe {
cmd.pre_exec(move || {
if libc::login_tty(secondary_fd) != 0 {
panic!("couldn't set the controlling terminal or something");
}
Ok(())
})
};
let mut child = cmd.spawn()?;
let mut out = vec![];
let mut buf = vec![0u8; 1024];
let mut primary = unsafe { File::from_raw_fd(primary_fd) };
'weee: loop {
tokio::select! {
n = primary.read(&mut buf) => {
let n = n?;
println!("Read {} bytes", n);
out.extend_from_slice(&buf[..n]);
},
status = child.wait() => {
status?;
println!("Child exited!");
break 'weee
},
}
}
println!("{}", String::from_utf8(out)?);
println!("Ok we're gonna return now");
Ok(())
}
Works like a charm. No channels or explicit threads involved. (tokio's worker threads don't count, this probably would work on the single-threaded runtime as well).
Nice, nice... but what's this ^C
at the end?
Nothing.
What?
IT'S NOTHING. It's an exercise left to the reader.
But doesn't that mean we-
So the program hangs at the end of main. Big deal. We could just call
std::process::exit
! That would definitely cut things short.
Speaking of cutting things short, that's all I have for you today. As always, if you've liked this video and would like to see more like i- I mean, uhh, I hope you enjoyed the article, and until next time — take care!
Updates and follow-ups
At this point in time, my audience is large enough and varied enough that every time I write about something, folks will jump in with an extra dose of cursed knowledge. And this time was no exception!
First, Maya actually did the exercise left to the reader and figured out why the async/tokio version was hanging at the end of "main". It is a very interesting read, and it showcases the new and still-experimental tokio console.
Go read it: Terminating the terminal case of Linux
Second, a whole bunch of folks mentioned that there are ways other than ioctls
to, for example, tell terminal size.
Apparently you can
move the cursor to the bottom right corned, write \e[6n
to stdout and it will
reply with the cursor position. For xterm-compatible
terminals, you
can send \e[18t
and they'll reply with \e[8;{rows};{cols}t
, and you don't
even have to move the cursor!
So there are ways to do these in-band, which comes in real handy when all you have is the stream itself, for reverse shells over the network or virtual character devices for virtual machines.
Also, vim uses that trick to determine whether a terminal is in East Asian font mode.
Re: zsh not executing the commands we echo
into its pseudo-terminal,
apparently there's
an ioctl
named TIOCSTI
that lets you simulate terminal input, and thus, inject commands. Fun!
Thanks to y'all for sharing additional cursedness — old systems are always entertaining to look at.
Here's another article just for you:
Rust modules vs files
A while back, I asked on Twitter what people found confusing in Rust, and one of the top topics was "how the module system maps to files".
I remember struggling with that a lot when I first started Rust, so I'll try to explain it in a way that makes sense to me.
Important note
All that follows is written for Rust 2021 edition. I have no interest in learning (or teaching) the ins and outs of the previous version, especially because it was a lot more confusing to me.