Skip to content

TEST: macOS low-fd verification (not for upstream) - #1

Closed
bernardladenthin wants to merge 18 commits into
mainfrom
test-macos-lowfd
Closed

TEST: macOS low-fd verification (not for upstream)#1
bernardladenthin wants to merge 18 commits into
mainfrom
test-macos-lowfd

Conversation

@bernardladenthin

Copy link
Copy Markdown
Owner

Throwaway branch to reach a macOS runner. Combines subprocess.h sheredom#112 + the low-fd fix + both regression tests, with utest.h #188/#189/#190 vendored together.

The defect: sheredom#115 creates the stdio pipes close-on-exec, and both implementations then move them onto 0, 1 and 2. A duplication onto the same descriptor is a no-op, so an end that already landed on a standard descriptor keeps FD_CLOEXEC and exec closes the child's stream. glibc clears the flag there, Apple does not — so Linux and AIX cannot see this.

Locally: invariant test red without the fix, green with it; 444/444 on x86_64 in both implementations. macOS is what this run is for.

Close when answered.

mehendarkarprajwal and others added 18 commits August 7, 2026 03:02
sheredom#104)

* Fix building against glibc older than 2.29

posix_spawn_file_actions_addchdir_np arrived in glibc 2.29, so
subprocess_create_ex fails to compile and link on older systems such as
manylinux2014. Add a SUBPROCESS_HAVE_CWD probe, report a requested cwd
as ENOSYS where the call is unavailable, and skip the cwd test there.

* Allow overriding SUBPROCESS_HAVE_CWD and document it

musl gained posix_spawn_file_actions_addchdir_np in 1.1.24 but exposes no
version macro, so the detection cannot cover older releases. Skip it when the
macro is already defined, letting those users set it themselves.

* Fix building against macOS older than 10.15

posix_spawn_file_actions_addchdir_np arrived in macOS 10.15, so older SDKs
neither declare nor export it and subprocess_create_ex fails to compile, as
reported for MacPorts on PowerPC in sheredom#108. A newer SDK paired with an older
deployment target does compile, but warns and leaves the binary referencing
a symbol the target system does not have.

Extend the SUBPROCESS_HAVE_CWD probe with a Darwin arm. It deliberately omits
a defined() guard on MAC_OS_X_VERSION_MIN_REQUIRED: an absent macro folds to
0, so a toolchain that does not supply it answers "no cwd" rather than
calling a function that may not be there. That also gives the right answer on
iOS, tvOS and watchOS, where the SDKs mark the call unavailable outright.
GCC 8 implements C++20 under the pre-release spelling only. CMake still lists
cxx_std_20 as supported there because it maps the feature onto -std=c++2a, so
the feature list cannot tell us which spelling the compiler accepts. Probe the
flag itself instead.
* Fix building against glibc older than 2.29

posix_spawn_file_actions_addchdir_np arrived in glibc 2.29, so
subprocess_create_ex fails to compile and link on older systems such as
manylinux2014. Add a SUBPROCESS_HAVE_CWD probe, report a requested cwd
as ENOSYS where the call is unavailable, and skip the cwd test there.

* Allow overriding SUBPROCESS_HAVE_CWD and document it

musl gained posix_spawn_file_actions_addchdir_np in 1.1.24 but exposes no
version macro, so the detection cannot cover older releases. Skip it when the
macro is already defined, letting those users set it themselves.

* Report a missing executable on glibc older than 2.24

* Restore the exec-error probe lost in the merge

Merging main after sheredom#104 dropped the SUBPROCESS_SPAWN_REPORTS_EXEC_ERRORS
block while keeping its use site, so the macro was undefined and folded to 0.
That turned the access() pre-check on unconditionally, on every platform,
which is the opposite of what this change intends.
…om#101)

* Fix Windows argument quoting for backslash runs before quotes

The command line builder doubles a backslash only when the next source
character is a quote. Per the CRT parsing rules, every backslash in a
run preceding a double quote must be doubled, and this includes a run
that ends up before the generated closing quote of a quoted argument.

As a result, a quoted argument ending in a backslash swallows its
closing quote and merges with every following argument, a run of two
or more backslashes before an embedded quote is under-escaped, and a
quoted argument ending in several backslashes loses half of them.

Count backslash runs in the length and emit passes and double the
whole run before an escaped quote or before the closing quote. Extend
the special argv test with the three cases plus a merge canary.

* Simplify Windows argument quoting passes

Rework the two command line builder passes without changing the
emitted bytes. The length pass becomes a linear scan that carries
the current backslash run, and the emit pass folds the duplicated
write loops into the explicit 2n + 1 and 2n CRT formulas.

Extend the special argv test with a quoted argument combining a
space and a backslash run before an embedded quote.

* Add mixed Windows backslash quoting test

🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol

---------

Co-authored-by: Neil Henning <me@neilhenning.dev>
🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol
Merges mehendarkarprajwal/subprocess.h@e6cec1b ("Initial port for AIX").
AIX provides no posix_spawn_file_actions_addchdir[_np], so process_cwd cannot
be honoured through posix_spawn there; the child has to be forked so it can
chdir() before exec.

The branch was based on 8671cee and conflicted with five commits that have
since touched subprocess.h (sheredom#102, sheredom#104, sheredom#105, sheredom#106, sheredom#110). Rather than replay
the incoming restructuring of the posix_spawn path, the conflict is resolved in
favour of main for that path, and the fork() implementation is added beside it.
The result is +183/-7 against main instead of +227/-97: the posix_spawn code is
left byte-identical, so no other platform can regress.

Changes relative to the merged commit:

  * Select the implementation through SUBPROCESS_SPAWN_VIA_FORK, defaulting to
    1 on AIX, instead of testing _AIX at each site. The path can then be forced
    on for testing on any POSIX platform, which is how it was validated: with
    -DSUBPROCESS_SPAWN_VIA_FORK=1 on glibc, all 441 tests pass.

  * Derive SUBPROCESS_HAVE_CWD and SUBPROCESS_SPAWN_REPORTS_EXEC_ERRORS from
    it. Both are true whenever the fork path is used, because the child chdir()s
    itself and the error pipe reports exec failures. Previously both landed on
    1 for AIX through the unknown-platform #else, which happened to be right for
    the wrong reason.

  * Drop the duplicate function-scope declarations of posix_error and actions,
    which shadowed the ones in the new inner block and left actions_created
    without a use. As merged, that broke every non-AIX build under -Werror:
    three unused-variable errors at subprocess.h:1137-1139.

  * Name the child's fallback exit status SUBPROCESS_EXEC_FAILURE_STATUS rather
    than repeating 127 at six call sites. No header defines the value; 127 is
    the shell convention for a command that could not be executed, and what
    glibc's posix_spawn used before it learned to report exec failures.

  * Restart read() and waitpid() on EINTR, and require a full sizeof(int) from
    the error pipe. A short or interrupted read previously reported success for
    a child whose exec had failed.

  * Only close the pipe ends in the child once they have been duplicated, and
    only those above STDERR_FILENO, so a pipe end that already occupies 0, 1 or
    2 is not closed out from under the descriptor just installed.

  * Route the child's failure paths through a single exit point and keep every
    call it makes async-signal-safe, which fork() in a threaded process requires.

Not yet verified on AIX itself; the test evidence above is from glibc with the
fork path forced.
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.
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.
🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol
* Restrict Windows subprocess handle inheritance

🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol

* Fix Windows test security attribute cast

🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol

* Match Windows attribute list API types

🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol

* Verify inherited event identity in Windows test

🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol

* Only define Windows handle test on Windows

🧙 Conjured by AI via [pi.dev](https://pi.dev/) using gpt-5.6-sol
Review feedback on sheredom#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.
Review feedback on sheredom#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.
Review feedback on sheredom#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.
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.
Not for upstream. This exists so the fork's own CI compiles the combination we
cannot reproduce locally: no macOS here, and the defect below is invisible on
Linux because glibc clears FD_CLOEXEC on a self-duplication where Apple does
not.

Contents, which live in five separate upstream PRs:

  subprocess.h  sheredom#112 (aix-port at eadfac5)
                + the low-fd fix this branch exists to test
  test/test.c   sheredom#112's fd-0 regression test
                + a new invariant test: no pipe end may sit on 0, 1 or 2
  test/utest.h  utest.h #188 reworked, #189 and #190, vendored together

The defect being tested: sheredom#115 creates the stdio pipes close-on-exec, and both
implementations then move them onto 0, 1 and 2 with dup2 — or with
posix_spawn_file_actions_adddup2, which Apple documents as behaving "as if
dup2() had been called". A duplication onto the same descriptor is a no-op, so
a pipe end that already landed on a standard descriptor keeps FD_CLOEXEC and
exec closes the child's stream. macOS CI caught this on sheredom#112 through the fd-0
test; Linux and AIX cannot see it.

subprocess_fds_above_std moves such an end out of the way, which removes the
self-duplication for both implementations rather than patching each.

Measured before pushing: the new invariant test goes red on unpatched main and
green with the fix; the full suite is 444/444 on x86_64 glibc in both
implementations, and the -D_AIX arm type-checks. macOS is what this branch is
for.
@bernardladenthin

Copy link
Copy Markdown
Owner Author

Test branch, its purpose is served: subprocess.h#118 is merged.

@bernardladenthin
bernardladenthin deleted the test-macos-lowfd branch August 22, 2026 11:20
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.

5 participants