`stty size` hangs forever (SIGTTOU) when run from a background process group
Nobody has claimed this yet.
Assessment
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Newbie friendliness
- 76/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Quiet
- Tech stack
- rust
- Domain
- cli, operating-systems
Research direction
Start in src/uu/stty/src/stty.rs, especially the settings branch around the unconditional tcsetattr() and the PrintSetting::Size handling. Run the provided repro.sh from an interactive terminal, then verify that stty size exits normally from a background process group while invocations that change settings still work.
Written by the indexing model from the issue text.
Description
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.
- Dominant language
- Rust
- Stars
- 24.1k
- Forks
- 2k
- Avg merge
- 1d 5h
- Merged PRs (30d)
- 365
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from uutils/coreutils
-
U - ls
Difficulty 2/5 1-3 hours Newbie friendliness 70/100
-
U - ls
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
-
U - tr
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
U - date
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
-
U - date
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
All issues in uutils/coreutils
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 86/100
kwakseongjae/auto-hwp#319 ·
-
area:cli bug filter-quality good first issue priority:medium
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
-
Difficulty 1/5 Under an hour Newbie friendliness 72/100
bevyengine/bevy#25861 ·
-
comp-datalake
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
ClickHouse/ClickHouse#121222 ·
-
enhancement remote
Difficulty 2/5 1-3 hours Newbie friendliness 68/100