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:
- Writing to
stdout
orstderr
. - Changing terminal settings.
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:
- You don’t want background processes consuming input (that input is probably destined for another process!).
- You’re probably OK with background processes producing output. Yet; perhaps you’re not, and so you’re happy with this being configurable.
- You’re probably not OK with background processes changing tty configuration (since this might affect other processes that are using the same TTY!).
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:
- Ignore it.
- Block it.
- Install a signal handler for it.
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:
- Whether
TOSTOP
is set for the TTY - Whether the process is ignoring or blocking
SIGTTOU
, has installed a signal handler, or is using the default behavior. - Whether you are producing output or setting terminal configuration.
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.