Skip to content

stty size hangs forever (SIGTTOU) when run from a background process group #13722

Description

@XhstormR

Summary

stty size is a read-only query, but uutils unconditionally calls tcsetattr() before
returning. When the process is in a background process group, POSIX requires the kernel
to send SIGTTOU on tcsetattr(), whose default disposition is stop. The process is
frozen in state T forever and never reaps, because nothing ever sends SIGCONT.

GNU coreutils does not have this problem: it guards the tcsetattr() call behind a
require_set_attr flag which size never sets.

The visible symptom is a hard hang — not an error — so any script that runs
stty size off the foreground (job-control &, a pager/previewer child, a shell hook)
deadlocks with no diagnostic.

Version

$ stty --version
stty (uutils coreutils) 0.9.0

$ coreutils --version
coreutils 0.9.0 (multi-call binary)
  • Installed from conda-forge (pixi global install uutils-coreutils)
  • Platform: macOS 26 (Darwin 25.5.0, xnu-12377.121.10~1), arm64 (Apple Silicon / T6041)

Steps to reproduce

Save as repro.sh and run it from an interactive terminal (a real controlling TTY is
required; set -m enables job control so that & puts the child in its own background
process group):

#!/usr/bin/env bash
set -m   # enable job control -> background jobs get their own process group

STTY=${1:-stty}
echo "testing: $STTY"

"$STTY" size < /dev/tty > /dev/null 2>&1 &
pid=$!
sleep 1
state=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ')
if [[ $state == T* ]]; then
  echo "RESULT: HUNG (state=$state) -- stopped by SIGTTOU"
  kill -9 "$pid" 2>/dev/null; kill -CONT "$pid" 2>/dev/null
else
  echo "RESULT: ok (state=${state:-exited})"
fi
wait "$pid" 2>/dev/null
$ bash repro.sh /path/to/uutils/stty
testing: /path/to/uutils/stty
RESULT: HUNG (state=T) -- stopped by SIGTTOU

$ bash repro.sh /bin/stty
testing: /bin/stty
RESULT: ok (state=exited)

Expected behaviour

stty size prints the terminal dimensions and exits 0, regardless of whether the calling
process is in the foreground or background process group — matching GNU coreutils and
BSD stty.

Actual behaviour

The dimensions are printed correctly, and then the process is stopped by SIGTTOU and
never exits. This confirms the tcsetattr() is a pure no-op that serves no purpose on this
code path:

process state : T
stdout bytes  : 7
stdout content: 54 207

Root cause

In src/uu/stty/src/stty.rs,
"size" is parsed as a settings argument (line 360), which routes it into the
mutating branch (if let Some(args) = &opts.settings, line 275). That branch ends with an
unconditional tcsetattr() at line 439:

if let Some(args) = &opts.settings {
    // ...
    "size" => {
        valid_args.push(ArgOptions::Print(PrintSetting::Size));   // read-only intent
    }
    // ...
    let mut termios = tcgetattr(opts.file.as_fd())...?;
    for arg in &valid_args {
        match arg {
            // ...
            ArgOptions::Print(setting) => {
                print_special_setting(setting, opts.file.as_raw_fd())?;   // TIOCGWINSZ, prints, done
            }
            // ...
        }
    }
    tcsetattr(opts.file.as_fd(), set_arg, &termios)?;   // <-- line 439: writes back unchanged termios
} else {
    let termios = tcgetattr(opts.file.as_fd())...?;     // read-only path, no tcsetattr
    print_settings(&termios, opts)?;
}

PrintSetting::Size only issues a TIOCGWINSZ ioctl and prints
(print_special_setting, line 553). termios is never modified, so the tcsetattr() at
line 439 writes back a byte-identical structure — a no-op in effect, but still a
terminal-modifying syscall as far as the kernel is concerned, and therefore still a
SIGTTOU trigger.

Note that SIGTTOU here is unconditional and unrelated to the TOSTOP termios flag:
TOSTOP only governs ordinary write(2) from a background process group, whereas
tcsetattr() from a background process group always raises SIGTTOU.

Comparison with GNU coreutils

GNU src/stty.c tracks whether any argument actually requested a modification and skips
the syscall entirely otherwise:

  require_set_attr = false;
  apply_settings (/* checking= */ false, device_name, argv, argc,
                  &mode, &require_set_attr);

  if (require_set_attr)
    {
      /* ... */
      if (tcsetattr (STDIN_FILENO, tcsetattr_options, &mode))
        error (EXIT_FAILURE, errno, "%s", quotef (device_name));
      /* ... */
    }

The size handler in GNU only reads and displays, and never sets require_set_attr:

  else if (streq (arg, "size"))
    {
      if (checking)
        continue;
      max_col = screen_columns ();
      current_col = 0;
      display_window_size (false, device_name);
    }

So GNU never reaches tcsetattr() for stty size and never raises SIGTTOU.

Signal attribution

To prove SIGTTOU (and not SIGTTIN) is responsible, the child is forked into its own
process group and the candidate signal is set to SIG_IGN before exec (ignore
dispositions survive exec). T = stopped, Z = ran to completion:

command signal ignored state result
uutils stty size none T hangs
uutils stty size SIGTTIN T still hangs → not a read issue
uutils stty size SIGTTOU Z exits normally → confirms the write
uutils stty -a none Z fine (read-only branch)
uutils stty (no args) none Z fine (read-only branch)
/bin/stty size none Z fine

Reproduction script for the table:

#!/usr/bin/env python3
"""Run stty in a background process group; identify the stopping signal."""
import os, signal, time

tty = open("/dev/tty", "r+b", buffering=0)
U = "/path/to/uutils/stty"   # <-- adjust

def probe(exe, args, block=None, label=""):
    pid = os.fork()
    if pid == 0:
        os.setpgid(0, 0)                 # own process group, NOT the foreground one
        if block:
            signal.signal(block, signal.SIG_IGN)
        os.dup2(tty.fileno(), 0)
        os.execv(exe, [exe, *args])
        os._exit(127)
    time.sleep(1.5)
    st = os.popen(f"ps -o stat= -p {pid}").read().strip()
    print(f"{label:44s} stat={st!r:6s} -> {'HUNG' if st.startswith('T') else 'exited'}")
    if st:
        os.kill(pid, signal.SIGKILL)
        try: os.kill(pid, signal.SIGCONT)
        except ProcessLookupError: pass
    try: os.waitpid(pid, 0)
    except ChildProcessError: pass

probe(U, ["size"], None,            "uutils stty size")
probe(U, ["size"], signal.SIGTTIN,  "uutils stty size (SIGTTIN ignored)")
probe(U, ["size"], signal.SIGTTOU,  "uutils stty size (SIGTTOU ignored)")
probe(U, ["-a"],   None,            "uutils stty -a")
probe("/bin/stty", ["size"], None,  "BSD /bin/stty size")

Scope

Only size is affected. Other invocations either take the read-only else branch or are
rejected before reaching tcsetattr():

argument uutils /bin/stty
(no args) ok ok
-a ok ok
-g ok ok
size HUNG ok
speed ok (not implemented: invalid argument 'speed', exit 1) ok

All three ways of designating the terminal hang identically, so the bug is in the code
path and not in how the fd is obtained:

invocation result
stty size < /dev/tty HUNG
stty size (inheriting shell stdin) HUNG
stty -F /dev/tty size HUNG
/bin/stty size < /dev/tty ok

All of the above work fine in the foreground (54 207, exit 0) — which makes this
bug easy to miss, because every manual attempt to reproduce it interactively succeeds.

Suggested fix

Track whether any argument actually requests a modification, and skip tcsetattr() when
none does — mirroring GNU's require_set_attr. A minimal version: since
ArgOptions::Print is by definition read-only, only call tcsetattr() when valid_args
contains at least one non-Print variant.

let requires_set_attr = valid_args
    .iter()
    .any(|arg| !matches!(arg, ArgOptions::Print(_)));

// ...

if requires_set_attr {
    tcsetattr(opts.file.as_fd(), set_arg, &termios)?;
}

This also avoids a spurious tcsetattr() for any future read-only PrintSetting variants.

Reclassifying size out of the settings branch entirely would work too, but note that GNU
accepts size intermixed with real settings (e.g. stty -echo size), so the
require_set_attr approach preserves compatibility better.

Real-world impact

This was originally diagnosed as a hang in fzf's image preview
(junegunn/fzf#4870). fzf-preview.sh calls
stty size < /dev/tty to obtain the total terminal height, in order to avoid a sixel
scrolling artifact:

elif ! [[ $KITTY_WINDOW_ID ]] && ((FZF_PREVIEW_TOP + FZF_PREVIEW_LINES == $(stty size < /dev/tty | awk '{print $1}'))); then

fzf runs preview commands in a background process group (it must keep itself in the
foreground to own the keyboard), so stty size is stopped by SIGTTOU, the preview command
never returns, and fzf displays Loading .. indefinitely. Process state at the time of the
hang:

  PID  PPID  PGID TPGID STAT COMMAND
73892 73867 73867 73867 S+   fzf                       <- PGID == TPGID: foreground
73898 73892 73898 73867 T    fish -c fzf-preview.sh
73946 73898 73898 73867 T    bash fzf-preview.sh
73950 73946 73898 73867 T    stty size                 <- stopped, never reaped

Because uutils installs stty ahead of /bin/stty on PATH, this silently breaks any
tool that queries the terminal size from a non-foreground context. The failure mode is a
silent deadlock rather than an error message, which makes it disproportionately hard to
attribute — the bug was initially reported against fzf, then suspected in chafa, before
being traced here.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions