Skip to content

fix: report an exec failure as a spawn error, not a SIGABRT child - #566

Merged
vyncint merged 7 commits into
mainfrom
fix/exec-failure-is-a-spawn-error
Oct 14, 2026
Merged

vyncint merged 7 commits into
mainfrom
fix/exec-failure-is-a-spawn-error

Conversation

@vyncint

@vyncint vyncint commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

What & why

On Unix, a program that exists but cannot be executed made spawn return
Ok with a child already dead of SIGABRT. termlens inspect drew a screen
holding fatal runtime error: assertion failed: output.write(&bytes).is_ok(),
reported exited: killed by signal: Aborted, and exited 0. portable-pty's
pre_exec closes every descriptor above stdio, and std's close-on-exec
exec-error pipe is one of them (upstream: wezterm/wezterm#7893, still open).

On Unix termlens now starts the child itself (src/spawn.rs), building the
command portable-pty 0.9.0 would build: the same resolution, argv[0],
working directory (with portable-pty's fallback to the child's HOME),
environment, session and signals. Descriptors above stdio are marked
close-on-exec instead of closed, so a failed exec reports through std. A
unit test starts one probe through both spawns and requires the same
answers (argv[0], directory, environment names plus a checksum of the
values, descriptors, umask, process group, controlling terminal), so a
portable-pty update cannot drift away unseen.

The error names the culprit where the errno would point at the wrong file:

cannot execute /w/./badinterp: its #! interpreter "/nonexistent/interpreter" does not exist
cannot execute /w/./crlf: its #! interpreter "/bin/sh\r" does not exist
cannot execute /w/./x: its #! interpreter "/w/plain-text" is not an executable file
cannot execute /w/./noloader: No such file or directory (os error 2); the file exists, so what is missing is its dynamic loader
cannot enter /w/locked: Permission denied (os error 13)

A failure before the exec (a NUL in a variable, the fork) reads as std
reports it, as before. Four rare setups still go to portable-pty unchanged,
and docs/DESIGN.md lists them: children reaped by the kernel (std would
panic), no usable directory and no HOME, a program that does not resolve
(portable-pty's own diagnosis), and a slave that cannot be opened by name.

Also in this PR:

  • dup_writer duplicates the master with std's close-on-exec
    BorrowedFd::try_clone_to_owned. Its plain dup leaked into every other
    child the test process started while a terminal was alive.
  • XTGETTCAP's TN reports the last TERM set, the one the child gets.
  • SECURITY.md lists the five unsafe blocks: borrow_raw, access(2),
    a sigaction(2) query, kill(2), and the pre_exec hook. DESIGN §1
    gains "On Unix termlens starts the child itself", which covers the
    lifecycle lock now held through the exec and the openpty close-on-exec
    window in the PTY layer. AGENTS.md and CONTRIBUTING §3 point at
    spawn.rs, and the skill's failure table gains the new message.

Closes #563

Review

The code-review skill ran at max effort on the first version and returned
15 findings. All of them are addressed in 95f1e77, or documented where they
are inherent:

  • The lock is held through the exec, about 0.25 ms per spawn on Linux.
    That is the price of reporting the exec at all.
  • The openpty window belongs to the PTY layer.
  • The PATH snapshot only matters under an unsound concurrent set_var.

CI then found two problems in the new comparison test itself, both fixed:

  • a read could stop between the marker and its line ending (edef00d);
  • on macOS the test waited on the shell without draining the terminal, and
    a process exiting there waits for its queued output (the echoed answer)
    to be read, so every macOS stress shard hung (088aca6, b6c101a).

Each probe now closes its PTY under the lifecycle lock and runs under a
deadline. A hang reports ps and stty state instead of stalling the job.

Tests

Each was checked to fail without its part of the fix:

  • process.rs: a missing interpreter, a CRLF interpreter, an interpreter
    that is not a program, an execute-only script, an E2BIG argument, a NUL
    in a variable and an unenterable directory.
  • descriptors.rs, a binary of its own: no termlens descriptor reaches an
    ordinary child, and none of the test's reaches a termlens child.
  • inspect.rs: the example and termlens inspect both exit 2 on a missing
    interpreter.
  • queries.rs: TERM set twice.
  • spawn.rs unit test: the two spawns cannot be told apart. It fails on a
    changed argv[0], a missing setsid, or a changed variable value.

Verification

  • Behaviour diff: published 0.11.5 against this branch on 969 cases,
    covering render in five formats over 104 saved screens, diff pairs, the
    CLI surface, and 35 inspect programs. 967 are identical; the two that
    differ are the missing-interpreter and CRLF cases. Also checked by hand:
    the SIGCHLD-ignored case matches 0.11.5, and a deleted working directory
    runs in HOME as before.
  • Gates: clippy -D warnings for Linux, x86_64-pc-windows-msvc and
    aarch64-apple-darwin; the MSRV checks at 1.85.1; the full suite, 625
    passed and 0 failed; preflight green on the previous head.

Checklist

  • Linked an issue (or explained above why none exists)
  • Tests added/updated for the change
  • cargo fmt --all and cargo clippy --workspace --all-targets --all-features are clean
  • All commits are signed off (git commit -s) — see CONTRIBUTING.md §5
  • No AI attribution trailers (no AI co-authors, "Generated with" footers, or bot identities) — see CONTRIBUTING.md §6
  • CHANGELOG.md updated under [Unreleased] (user-facing changes only)
  • Snapshot changes (if any) were reviewed with cargo insta review, not blind-accepted

@vyncint
vyncint force-pushed the fix/exec-failure-is-a-spawn-error branch from 2aae388 to 12a4fed Compare October 14, 2026 09:40
On Unix, a program that exists but cannot be executed made `spawn`
return Ok with a child already killed by SIGABRT. Examples are a
missing #! interpreter, a shebang ending in \r from CRLF line endings,
a missing dynamic loader, and an argument list over the kernel's
limit. portable-pty's pre_exec closes every descriptor above stdio,
and std's exec-error pipe is one of them, so the child aborted on the
write that would have reported the failure. termlens inspect showed
it as "exited: killed by signal: Aborted" and exited 0.

On Unix termlens now starts the child itself (src/spawn.rs). It builds
the command portable-pty would build: the same program resolution,
working directory, environment, session and signals. The one change
is that inherited descriptors are marked close-on-exec instead of
closed. A successful exec closes the same set as before, and a failed
one reports through std. The error names the culprit: the #!
interpreter, shown escaped so that a stray \r is visible, or the
dynamic loader. Cases the new path is not sure of still go to
portable-pty unchanged, with its diagnostics, and Windows is
untouched.

The responder thread's duplicate of the PTY master is now opened with
F_DUPFD_CLOEXEC. As a plain dup it leaked into every other child the
test process started while a terminal was alive.

Closes #563

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
The diagnosis table gains the message termlens now gives for a script
whose #! interpreter does not exist, and what a \r at the end of the
quoted name means.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
@vyncint
vyncint force-pushed the fix/exec-failure-is-a-spawn-error branch from 12a4fed to 5568427 Compare October 14, 2026 09:49
AGENTS.md's layout names the module that now starts the child on
Unix, and lists it with the files to read DESIGN before touching.
CONTRIBUTING §3 asks a change to it for the stress workflow, as it
asks of wait semantics: every test in the suite goes through it.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
Review findings on the new spawn path:

- A failure before the exec no longer reads "cannot execute <program>".
  That covers a NUL in a variable, a directory the child cannot enter,
  and the fork itself. Such a failure reads as std reports it, and an
  unenterable directory is named.
- EACCES from an interpreter that is not a program names the
  interpreter. A script that can be run but not read names both suspects
  instead of guessing the loader. The #! line now ends at a NUL, and on
  macOS at '#', as the kernels read it, from 512 bytes.
- A working directory that is not a directory gives way to the child's
  HOME, as in portable-pty, instead of handing the spawn back to
  portable-pty. A process whose children the kernel reaps (SIGCHLD
  ignored, or SA_NOCLDWAIT) is handed back, because std would panic
  reporting the failure.
- A unit test starts one probe through both spawns and requires the same
  argv[0], directory, environment, descriptors, umask, process group and
  controlling terminal, so a portable-pty update cannot drift unseen.
- dup_writer duplicates with BorrowedFd::try_clone_to_owned, which is
  close-on-exec, and one unsafe block replaces two.
- XTGETTCAP's TN reports the last TERM set, the one the child gets.
- The descriptor probe runs in a hermetic environment and reads a line
  before it exits. The E2BIG test no longer Debug-prints a 2 MiB command
  line when it fails.
- DESIGN, SECURITY and the CHANGELOG describe the remaining fallback
  cases, the lifecycle lock now held through the exec, the openpty
  close-on-exec window, and the upstream issue (wezterm/wezterm#7893).

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
@vyncint vyncint changed the title fix: an exec failure is a spawn error, not a child dead of SIGABRT fix: report an exec failure as a spawn error, not a SIGABRT child Oct 14, 2026
The comparison test stopped reading once "probe-finished" had arrived,
and a read can end between the marker and its line ending. That side was
then one line shorter, and the two spawns looked different. It failed
on CI and on every Linux stress shard. Locally it failed 6 times in 200
concurrent runs before this change and 0 times in 800 after.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
On macOS the comparison test hung on every stress shard until the
30-minute job limit. Its teardown dropped the lifecycle guard before
the reader and writer, which are duplicates of the master, so the last
master close, the one macOS revokes the device on, happened outside
the lock. The next probe opened a PTY at once, and a late revoke
landing on that recycled device is the hazard DESIGN.md describes.
Every master handle now closes under the lock, as a Terminal's do.

Each probe also runs on a thread of its own under a 60-second
deadline. A hang then fails the test, naming the step it hung in and
what the probe had printed, instead of stalling the suite.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
With the lock fix in, macOS reported where the probe hung: in wait(),
after the test had answered the shell's read and stopped reading. The
terminal echoes the answer, and on macOS a process exiting with output
still queued waits for it to be read. The shell could not finish
exiting while the test waited for it. The probe now reads to the end,
as a Terminal's reader does, before it waits.

Both probes now run even when the first fails. A probe that hangs
reports the shell's state from ps and the terminal's from stty, beside
what it printed.

Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com>
@vyncint
vyncint merged commit 83c6a10 into main Oct 14, 2026
31 checks passed
@vyncint
vyncint deleted the fix/exec-failure-is-a-spawn-error branch October 14, 2026 12:50
@vyncint vyncint mentioned this pull request Oct 14, 2026
7 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An exec failure after the fork is a runtime abort, not a spawn error (a missing #! interpreter shows killed by signal: Aborted)

1 participant