Skip to content

Prevent POSIX subprocesses from inheriting pipes - #115

Merged
sheredom merged 1 commit into
mainfrom
fix/posix-pipe-cloexec
Aug 14, 2026
Merged

Prevent POSIX subprocesses from inheriting pipes#115
sheredom merged 1 commit into
mainfrom
fix/posix-pipe-cloexec

Conversation

@sheredom

Copy link
Copy Markdown
Owner

Summary

  • create POSIX stdin/stdout/stderr pipes with close-on-exec enabled
  • use atomic pipe2(O_CLOEXEC) where available, with a pipe/fcntl fallback
  • add a regression test proving a later subprocess cannot inherit an earlier subprocess's stdin pipe
  • preserve the original error and clean up both descriptors if close-on-exec setup fails

Fixes #114.

Testing

  • ctest --test-dir build --output-on-failure
  • 4/4 test configurations passed on macOS
  • the issue reproducer now reports only descriptors 0 1 2 3 4

🧙 Conjured by AI via pi.dev using gpt-5.6-sol

🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol
@sheredom
sheredom merged commit ab7fb8b into main Aug 14, 2026
24 checks passed
@sheredom
sheredom deleted the fix/posix-pipe-cloexec branch August 14, 2026 13:30
bernardladenthin added a commit to bernardladenthin/subprocess.h that referenced this pull request Aug 14, 2026
The merge brings in sheredom#115, which creates the stdio pipes close-on-exec. The
forked child relies on dup2 to strip that flag when it installs them on 0, 1
and 2 — and dup2 does clear it, except when source and target are the same
descriptor, where it is a no-op.

That case is reachable: with fd 0 free in the parent, the stdin pipe's read
end lands on 0 and the child's dup2(stdinfd[0], STDIN_FILENO) becomes
dup2(0, 0). exec then closes the child's stdin. Measured with a parent that
closes fd 0 before subprocess_create: this branch before the merge works,
upstream main works, the merge of the two does not, and clearing the flag
fixes it. fcntl is async-signal-safe, so the child stays within what it may
call before exec.

Nothing in the suite could see this. It stayed at 442/442 before and after
the fix, in both implementations, because no test ran with a standard
descriptor closed. The test added here does: it closes fd 0 around a
subprocess_create and asserts the child still has a stdin, using the
process_is_fd_open helper sheredom#115 brought along. It goes red on the fork path
without the fix above and green with it; on the posix_spawn path, which never
had the defect, it is green either way. The suite is now 443/443.

The test saves and restores fd 0 and defers its assertions until after the
restore — an assertion returns early in utest, which would otherwise leave
every later test in the binary without a stdin.
sheredom pushed a commit that referenced this pull request Aug 18, 2026
#115 creates the stdio pipes close-on-exec, and subprocess_create_ex then
installs them on 0, 1 and 2. Duplicating a descriptor onto itself is a no-op,
so an end that already landed on a standard descriptor keeps FD_CLOEXEC and
exec closes the child's stream. It happens whenever the parent runs with one of
those descriptors free.

posix_spawn_file_actions_adddup2 is documented on macOS as behaving "as if
dup2() had been called", with no special case for equal descriptors, and that
is where it bites. glibc applies the POSIX clarification and clears the flag,
which is why no Linux runner shows this.

subprocess_fds_above_std moves such an end out of the way at creation time.
That removes the self-duplication for every caller rather than patching each
site, and it needs no platform test.

The added test checks the precondition rather than the symptom: no pipe end may
sit on 0, 1 or 2. That is observable everywhere, and it goes red on unpatched
main — the symptom itself cannot be reproduced off macOS.

Measured: full suite green on x86_64 glibc, and green on macOS in a run that
combined this with the AIX branch and the pending utest.h changes, where the
same suite had been failing on exactly this before.
sheredom pushed a commit that referenced this pull request Aug 19, 2026
* Initial port for AIX

* Detect both the presence and the spelling of the chdir file action

Adds SUBPROCESS_ADDCHDIR_IS_POSIX, which says whether the platform provides
posix_spawn_file_actions_addchdir under its POSIX 2024 name rather than the
older ..._np one, and extends SUBPROCESS_SPAWN_VIA_FORK to the platforms that
provide neither and therefore need fork()+exec(). The call site now asks those
two macros instead of naming platforms itself.

  SUBPROCESS_ADDCHDIR_IS_POSIX   macOS 26+, NetBSD 10+
  SUBPROCESS_SPAWN_VIA_FORK      AIX, OpenBSD, NetBSD < 10

The reason for two macros rather than one is that platforms differ in two
independent ways: whether the chdir file action exists at all, and what it is
called. Taken from each vendor's own spawn.h and libc:

  FreeBSD 13.1, 13.2, 14.3, 15.0   ..._addchdir_np
  NetBSD 10.0, 10.1                ..._addchdir, no _np
  NetBSD 9.3, 9.4                  neither
  OpenBSD 7.7, 7.8, 7.9            neither
  AIX 7.2 TL04                     neither, in header and in libc

This also corrects the call site's previous test, defined(__NetBSD__) with no
version check, which was right for NetBSD 10 and wrong for 9 and older, where
neither spelling exists.

__NetBSD_Version__ comes from <sys/param.h>, not from the compiler's
predefines, so that header is now included on NetBSD before the probes run. If
it is ever absent the version folds to 0 and every NetBSD takes the fork path:
heavier than needed, never wrong.

Verified by compiling against sysroots built from each vendor's release sets,
so nothing here rests on documentation. Every C source of the test suite
compiles for NetBSD 9.4 and 10.1, OpenBSD 7.9 and FreeBSD 14.3 under the flags
CMakeLists assigns. macOS 10.13 through 14.0 compile through osxcross against a
real 15.5 SDK, with SUBPROCESS_HAVE_CWD 0 below 10.15 and 1 from 10.15 up; the
macOS 26 branch cannot be checked because no mirrored SDK goes that far.

Unchanged where it was already working: Linux glibc and musl 441/441, AIX 7.2
on POWER8 441/441, Windows MSVC 387/387 in 32- and 64-bit and against both CRTs.
Forcing -DSUBPROCESS_SPAWN_VIA_FORK=1 on glibc also passes 441/441, which is how
the fork path is exercised without leaving Linux. clang's static analyser
reports nothing on either path, and a purpose-built test spawning from eight
threads, 640 spawns, passes on both paths under AddressSanitizer.

* Skip the divide-by-zero assertion on PowerPC and RISC-V

The test already excludes AArch64 because SDIV by zero yields 0 instead of
trapping. Two more architectures behave the same way, and are specified to:
PowerPC's divw/divd leave the result undefined and raise no exception unless
OE is set, and RISC-V's DIV returns -1, all bits set. On both the child exits
0, so subprocess_fail_divzero cannot hold.

Measured with the same source and compiler on each architecture rather than
taken from the manuals. x86_64, i386, armv7l, armv6 and s390x die on signal 8;
aarch64 exits 0 printing 0, ppc64le exits 0, and riscv64 exits 0 printing -1 —
each matching what its ISA specifies. Reproduced on AIX 7.2 TL04 on POWER8,
where the suite now passes 441 of 441.

32-bit ARM was checked deliberately and does trap, including ARMv6, which has
no divide instruction at all and goes through __aeabi_idiv. The plausible
assumption that a software divide routine returns 0 quietly is wrong, so
__arm__ is not excluded.

_ARCH_PPC is listed beside __powerpc__ because IBM XL C and OpenXL define only
the former, and that is the compiler most AIX users reach for.

* Shorten the two probe comments the review flagged

Review feedback on #112.

The paragraph above the <sys/param.h> include is gone. It explained that
__NetBSD_Version__ is not a compiler predefine and that omitting the header
would fold the version to 0 — true, but the include sits directly under
#if defined(__NetBSD__) and needs no defence.

The SUBPROCESS_SPAWN_VIA_FORK comment keeps what the macro does, why forking
buys anything, and that it can be overridden. Dropped: the list of which
platforms lack addchdir, which the #if immediately below states in code, and
the sentence about the capabilities this enables further down.

Comments only. Preprocessing both revisions with -fpreprocessed -dD -E -P
leaves 1563 identical lines of code. Rebuilt anyway: 441/441 on x86_64 glibc,
and 441/441 again with -DSUBPROCESS_SPAWN_VIA_FORK=1.

* Drop the exec-failure status macro and exit 127 directly

Review feedback on #112: "Why do we even need this define?"

We do not. The value was used exactly once, no test referenced it, the README
never mentioned it, and it only ever reaches a caller down a path that
practically cannot happen — the parent reads the real errno off the pipe and
reaps the child with a discarded status, so 127 surfaces only if that 4-byte
write into an empty pipe is lost.

Making it configurable was worse than pointless. POSIX requires posix_spawn's
child to exit with status 127 when exec fails, so 127 is what keeps the fork
path indistinguishable from the posix_spawn path for a caller. The knob's only
possible use was to break that equivalence, and it offered nothing on the
posix_spawn side, where the value comes from libc and cannot be overridden.

The macro was introduced by this PR and never released, so nothing depends on
it. 441/441 on x86_64 glibc, and again with -DSUBPROCESS_SPAWN_VIA_FORK=1.

* Declare execvpe above the function instead of inside it

Review feedback on #112: "Could we only extern to this in a define block for
AIX above the function instead then?"

Moved out of the function body, but guarded on SUBPROCESS_SPAWN_VIA_FORK
rather than _AIX. Restricting it to AIX breaks glibc: execvpe is a GNU
extension there, so <unistd.h> only declares it when _GNU_SOURCE is set, and
a consumer forcing the fork path without that macro would lose the
declaration. Measured both ways with -Werror=implicit-function-declaration —
the AIX-only variant fails without _GNU_SOURCE and builds with it. The suite
cannot see this either way, since test/CMakeLists.txt defines _GNU_SOURCE on
Linux. musl hides execvpe the same way.

The declaration is also no longer AIX-specific in what it claims: OpenBSD and
NetBSD reach this code too, and both do have execvpe.

441/441 on x86_64 glibc, and again with -DSUBPROCESS_SPAWN_VIA_FORK=1. The
-D_AIX arm still type-checks, and the fork path compiles on glibc and musl in
C and C++, with and without _GNU_SOURCE — the C++ runs matter because glibc
declares execvpe __THROW, and a file-scope redeclaration without noexcept
would clash where the in-body one did not.

* Clear FD_CLOEXEC on the child's standard descriptors after dup2

The merge brings in #115, which creates the stdio pipes close-on-exec. The
forked child relies on dup2 to strip that flag when it installs them on 0, 1
and 2 — and dup2 does clear it, except when source and target are the same
descriptor, where it is a no-op.

That case is reachable: with fd 0 free in the parent, the stdin pipe's read
end lands on 0 and the child's dup2(stdinfd[0], STDIN_FILENO) becomes
dup2(0, 0). exec then closes the child's stdin. Measured with a parent that
closes fd 0 before subprocess_create: this branch before the merge works,
upstream main works, the merge of the two does not, and clearing the flag
fixes it. fcntl is async-signal-safe, so the child stays within what it may
call before exec.

Nothing in the suite could see this. It stayed at 442/442 before and after
the fix, in both implementations, because no test ran with a standard
descriptor closed. The test added here does: it closes fd 0 around a
subprocess_create and asserts the child still has a stdin, using the
process_is_fd_open helper #115 brought along. It goes red on the fork path
without the fix above and green with it; on the posix_spawn path, which never
had the defect, it is green either way. The suite is now 443/443.

The test saves and restores fd 0 and defers its assertions until after the
restore — an assertion returns early in utest, which would otherwise leave
every later test in the binary without a stdin.

* Drop the FD_CLOEXEC clearing now that pipe ends avoid 0, 1 and 2

subprocess_fds_above_std, added in #118, moves every pipe end above STDERR
on both pipe-creation paths, so dup2 in the child can no longer be a no-op
on a standard descriptor. Reverts the addition from this branch as promised
in #118.

---------

Co-authored-by: mehendarkarprajwal <prajwal.b.mehendarkar@ibm.com>
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.

POSIX pipes are not close-on-exec, so a later process inherits an earlier one's pipes

1 participant