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.
Summary
stty sizeis a read-only query, but uutils unconditionally callstcsetattr()beforereturning. 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 isfrozen in state
Tforever and never reaps, because nothing ever sendsSIGCONT.GNU coreutils does not have this problem: it guards the
tcsetattr()call behind arequire_set_attrflag whichsizenever sets.The visible symptom is a hard hang — not an error — so any script that runs
stty sizeoff the foreground (job-control&, a pager/previewer child, a shell hook)deadlocks with no diagnostic.
Version
pixi global install uutils-coreutils)xnu-12377.121.10~1), arm64 (Apple Silicon / T6041)Steps to reproduce
Save as
repro.shand run it from an interactive terminal (a real controlling TTY isrequired;
set -menables job control so that&puts the child in its own backgroundprocess group):
Expected behaviour
stty sizeprints the terminal dimensions and exits 0, regardless of whether the callingprocess 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 thiscode path:
Root cause
In
src/uu/stty/src/stty.rs,"size"is parsed as a settings argument (line 360), which routes it into themutating branch (
if let Some(args) = &opts.settings, line 275). That branch ends with anunconditional
tcsetattr()at line 439:PrintSetting::Sizeonly issues aTIOCGWINSZioctl and prints(
print_special_setting, line 553).termiosis never modified, so thetcsetattr()atline 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
TOSTOPtermios flag:TOSTOPonly governs ordinarywrite(2)from a background process group, whereastcsetattr()from a background process group always raises SIGTTOU.Comparison with GNU coreutils
GNU
src/stty.ctracks whether any argument actually requested a modification and skipsthe syscall entirely otherwise:
The
sizehandler in GNU only reads and displays, and never setsrequire_set_attr:So GNU never reaches
tcsetattr()forstty sizeand 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_IGNbeforeexec(ignoredispositions survive
exec).T= stopped,Z= ran to completion:stty sizeTstty sizeSIGTTINTstty sizeSIGTTOUZstty -aZstty(no args)Z/bin/stty sizeZReproduction script for the table:
Scope
Only
sizeis affected. Other invocations either take the read-onlyelsebranch or arerejected before reaching
tcsetattr():/bin/stty-a-gsizespeedinvalid argument 'speed', exit 1)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:
stty size < /dev/ttystty size(inheriting shell stdin)stty -F /dev/tty size/bin/stty size < /dev/ttyAll of the above work fine in the foreground (
54 207, exit 0) — which makes thisbug 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()whennone does — mirroring GNU's
require_set_attr. A minimal version: sinceArgOptions::Printis by definition read-only, only calltcsetattr()whenvalid_argscontains at least one non-
Printvariant.This also avoids a spurious
tcsetattr()for any future read-onlyPrintSettingvariants.Reclassifying
sizeout of the settings branch entirely would work too, but note that GNUaccepts
sizeintermixed with real settings (e.g.stty -echo size), so therequire_set_attrapproach preserves compatibility better.Real-world impact
This was originally diagnosed as a hang in fzf's image preview
(junegunn/fzf#4870).
fzf-preview.shcallsstty size < /dev/ttyto obtain the total terminal height, in order to avoid a sixelscrolling artifact:
fzf runs preview commands in a background process group (it must keep itself in the
foreground to own the keyboard), so
stty sizeis stopped by SIGTTOU, the preview commandnever returns, and fzf displays
Loading ..indefinitely. Process state at the time of thehang:
Because uutils installs
sttyahead of/bin/sttyonPATH, this silently breaks anytool 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.