A Deep Dive into the SIGTTIN / SIGTTOU Terminal Access Control Mechanism in Linux

From an end-user perspective, the TTY system in Linux (or any POSIX-like OS) is both functional and intuitive. For example, the input you type in usually goes to the process you expect it to go to, CTRL+Z usually suspends the process you expect to see suspended, CTRL+C usually interrupts the process you expect to see interrupted, and so on.

However, from an application developer perspective, things are… hairier! There’s a lot to say, but this blog post will focus specifically SIGTTOU / SIGTTIN mechanism, which is essentially how POSIX implements access control to TTY functionality, e.g. ensuring that background processes don’t inadvertently read input that isn’t destined for them.

This mechanism is both complex and sparsely documented (there are a few references in the Linux credentials man page and the Bash documentation, but nothing comprehensive). This post is my attempt at fixing that!

Throughout this blog post, I’ll be using Python 3.4 (and stty) to demonstrate what’s going on, but it should still be easy to follow even if you’re not very knowledgeable about Python. However, familiarity with basic TTY concepts is recommended. If you’re unsure what a “background process” and a “controlling terminal” are, I recommend you start with “The TTY demystified” — it’s a fantastic introduction.

 What are SIGTTIN and SIGTTOU?

Both are signals that are sent to background processes that they attempt to read from (SIGTTIN) or write to (SIGTTOU) their controlling
terminal (or tty).

For example, SIGTTIN is the signal that causes Python to suspend in the following shell session when it attempts to read from stdin, which is connected to a tty that Python isn’t the foreground process for:

$ python3.4 -c 'input()' &
[1] + Stopped (tty input)        python3.4 -c "input()"

In other words, SIGTTIN is the Kernel’s way of telling Python that it won’t get input just now because it’s not in the foreground. If Python is brought back to the foreground, the shell would send a SIGCONT signal to instruct Python to resume (and Python would have to retry read()-ing).

SIGTTOU does something similar, but for writes. However, note that “writing” to the TTY can mean either of two things:

But, SIGTTOU‘s behavior is actually more complex than SIGTTIN’s. To start, SIGTTOU is not always sent when a background process attempts to write!

$ python3.4 -c 'print("Hello")' &
Hello
[1] - Done                       python3.4 -c "print(\"Hello\")"

(whether or not SIGTTOU is sent here is dependent on your TTY configuration — more on that below)

However, independent of your TTY configuration, changing terminal settings does cause SIGTTOU to be sent:

$ python3.4 -c 'import tty, sys; tty.setcbreak(sys.stdin)' &
[1] + Stopped (tty output)       python3.4 -c "import tty, sys; tty.setcbreak(sys.stdin)"

Once again, this actually makes a lot of sense from the end-user perspective:

However, as an application developer, dealing with SIGTTIN and SIGTTOU is more tricky. Not only there are configuration options, but the way you configure signal handling in your application plays a role too. In the end, it’s not always easy to anticipate whether a write will lock up your process with SIGTTOU.

This bit me recently in Tini, and I had to do quite a bit of reading to get a good understanding of when to expect SIGTTOU (to avoid it). Here’s what I found.

 TOSTOP

The above discrepancy between producing output and setting tty attributes is controlled by the TOSTOP TTY mode. On most platforms I’ve tried, this output mode is not set by default, but you can check your actual settings using the stty command.

If TOSTOP is enabled, it will show in stty’s output:

$ stty tostop
$ stty
speed 38400 baud; line = 0;
-brkint -imaxbel
tostop

You can enable this mode using stty tostop, and disable it using stty tostop. Remember that this is a TTY mode, so if you try this in a subshell, it will affect the parent shell as well, as they share the same TTY!

Now, what does TOSTOP do? When this mode is set, all writes (output and terminal configuration) trigger SIGTTOU:

$ stty tostop
$ python3.4 -c 'print("Hello")' &
[1] + Stopped (tty output)       python3.4 -c "print(\"Hello\")"

However, when this mode is not set, then only terminal configuration writes trigger SIGTTOU (we saw that case above).

If you’re curious, here’s the Linux code that calls tty_check_change only if TOSTOP is set when writing, and here’s the code that calls it when changing terminal configuration. You’ll note that not all terminal configuration access actually calls tty_check_change.

 Signal Configuration

Since SIGTTOU is a signal, you don’t have to live with the default behavior (which is to suspend your process).

Indeed, you can:

 Ignoring or Blocking SIGTTOU

If your process ignores SIGTTOU, then regardless of whether TOSTOP is set or not, SIGTTOU will not be sent, and output is allowed (regardless of whether you’re producing output or changing terminal settings):

$ stty tostop
$ python3.4 -c 'import signal; signal.signal(signal.SIGTTOU, signal.SIG_IGN); print("Hello")' &
Hello
[1] + Done                       python3.4 -c "import signal; signal.signal(signal.SIGTTOU, signal.SIG_IGN); print(\"Hello\")"

Blocking SIGTTOU is functionally equivalent to ignoring it.

However, note a surprising (or at least interesting) fact here: if you block SIGTTOU, then it doesn’t get sent at all. If you call sigwait or sigtimedwait later, you will not receive SIGTTOU.

In the Linux Kernel, both cases are actually handled by the same function: is_ignored (this function is only ever used with SIGTTOU and SIGTTIN; I’m not entirely sure why it’s named is_ignored since it checks for both ignored and blocked).

 Installing a handler for SIGTTOU

If you install a signal handler, your process will receive SIGTTOU depending on whether TOSTOP is set and on the action being performed (write or configuration), as explained earlier in this blog post.

If SIGTTOU is sent, then the registered signal handler is called, and the call to write fails; errno will be set to EINTR (for interrupted).

Note that Python 3 does not handle this too well, and goes into an infinite loop of retrying the failed write:

$ python3.4 -c 'import signal; signal.signal(signal.SIGTTOU, lambda _1, _2: None); print("Hello")' & strace -p $!

< ... Lots of output removed >

rt_sigaction(SIGTTOU, {0x46f178, [], SA_RESTORER, 0x7f44f3783340}, {SIG_DFL, [], 0}, 8) = 0
write(1, "Hello\n", 6)                  = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTTOU {si_signo=SIGTTOU, si_code=SI_KERNEL} ---
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
write(1, "Hello\n", 6)                  = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTTOU {si_signo=SIGTTOU, si_code=SI_KERNEL} ---
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
write(1, "Hello\n", 6)                  = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTTOU {si_signo=SIGTTOU, si_code=SI_KERNEL} ---
rt_sigreturn()                          = -1 EINTR (Interrupted system call)
write(1, "Hello\n", 6)                  = ? ERESTARTSYS (To be restarted if SA_RESTART is set)
--- SIGTTOU {si_signo=SIGTTOU, si_code=SI_KERNEL} ---
rt_sigreturn()                          = -1 EINTR (Interrupted system call)

< ... Goes on until the process is allowed to write >

 Summary

To summarize, what happens depends on:

Here’s a table summarizing all scenarios:

TOSTOP Signal Configuration Action Behavior
Not set Default Write Write is allowed.
Not set Default Configure SIGTTOU is sent (program is suspended, call fails with EINTR when resuming).
Not set Ignore SIGTTOU Write Write is allowed.
Not set Ignore SIGTTOU Configure Configuration is allowed.
Not set Block SIGTTOU Write Write is allowed.
Not set Block SIGTTOU Configure Configuration is allowed.
Not set Signal handler Write Write is allowed.
Not set Signal handler Configure Signal handler is called. Call fails with EINTR.
Set Default Write SIGTTOU is sent (program is suspended, call fails with EINTR when resuming).
Set Default Configure SIGTTOU is sent (program is suspended, call fails with EINTR when resuming).
Set Ignore SIGTTOU Write Write is allowed.
Set Ignore SIGTTOU Configure Configuration is allowed.
Set Block SIGTTOU Write Write is allowed.
Set Block SIGTTOU Configure Configuration is allowed.
Set Signal handler Write Signal handler is called. Call fails with EINTR.
Set Signal handler Configure Signal handler is called. Call fails with EINTR.

Note that there are a few other exceptions (e.g. PID 1 and orphaned processes in general are treated slightly differently), but those are minor.

 
128
Kudos
 
128
Kudos

Now read this

Getting A First Patch Into Docker

A few weeks ago, I was hitting what seemed like a weird bug using Docker. My use case was fairly off the beaten path, so I wasn’t overly surprised when it turned out to be a bug in Docker itself. Now, fortunately, this post isn’t a rant... Continue →