Skip to content

Support AIX by forking where posix_spawn cannot change directory - #112

Merged
sheredom merged 11 commits into
sheredom:mainfrom
bernardladenthin:aix-port
Aug 19, 2026
Merged

Support AIX by forking where posix_spawn cannot change directory#112
sheredom merged 11 commits into
sheredom:mainfrom
bernardladenthin:aix-port

Conversation

@bernardladenthin

@bernardladenthin bernardladenthin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

This continues @mehendarkarprajwal's #109, whose commit is included here
unchanged. It was opened because llama.cpp adopted subprocess.h for launching
llama-server in router mode and AIX does not build. I have since had an AIX 7.2
TL04 system to test on, so this brings the branch up to current main and
confirms it on the real platform.

What was wrong. SUBPROCESS_HAVE_CWD asks about glibc and macOS and hands
1 to everything else. AIX has no posix_spawn_file_actions_addchdir under
either spelling, so the build fails at link time:

subprocess.h:1265:19: warning: implicit declaration of function
  'posix_spawn_file_actions_addchdir_np'; did you mean 'posix_spawn_file_actions_addopen'?
ld: 0711-317 ERROR: Undefined symbol: .posix_spawn_file_actions_addchdir_np

SUBPROCESS_SPAWN_REPORTS_EXEC_ERRORS gets it wrong the same way, and that one
is silent rather than loud — AIX's posix_spawn returns success for a binary
that does not exist:

[ RUN      ] c11.create_ex_subprocess_create_failure_preserves_error
  Expected : (subprocess_error_not_found) == (subprocess_create_ex(...))
    Actual : -4 vs 0

On unpatched main, AIX needs -DSUBPROCESS_HAVE_CWD=0 merely to link and then
fails 27 of 441 tests: 18 from the exec-error problem above, 9 from a
PowerPC property described further down.

And AIX is not alone. Read from each vendor's own spawn.h and libc:

declares
FreeBSD 13.1–15.0 ..._addchdir_np fine
NetBSD 10.0, 10.1 ..._addchdir, no _np handled by #102
NetBSD 9.3, 9.4 neither #102's guard has no version test
OpenBSD 7.7–7.9 neither unhandled
AIX 7.2 neither this PR

What this does

Three commits on top of the merge:

  1. The merge itself. Initial port for AIX #109 was based on 8671cee; five commits have touched
    subprocess.h since (Fix build on NetBSD. #102, Fix building where posix_spawn_file_actions_addchdir_np is unavailable #104, Fall back to -std=c++2a on compilers without -std=c++20 #105, Report a missing executable on glibc older than 2.24 #106, Handle ENOSYS subprocess errors #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 — +183/-7 against main where Initial port for AIX #109 is +227/-97, so the posix_spawn
    code is byte-identical and no other platform can regress.

    Changes to @mehendarkarprajwal's code while merging: dropped the duplicate
    function-scope declarations that broke every non-AIX build under -Werror;
    named the child's fallback exit status instead of repeating 127 six times;
    restarted read() and waitpid() on EINTR and required a full
    sizeof(int) from the error pipe; and closed the pipe ends in the child only
    after duplicating them, and only those above STDERR_FILENO.

  2. Two questions instead of one. SUBPROCESS_ADDCHDIR_IS_POSIX says whether
    the POSIX 2024 spelling exists (macOS 26+, NetBSD 10+);
    SUBPROCESS_SPAWN_VIA_FORK says neither exists so fork is needed (AIX,
    OpenBSD, NetBSD < 10). The call site asks those rather than naming platforms,
    which also fixes Fix build on NetBSD. #102's version-less defined(__NetBSD__) guard — right for
    NetBSD 10, wrong for 9 and older.

  3. An architecture guard for create_subprocess_fail_divzero. PowerPC's
    divw/divd leave the result undefined and raise no exception, so the child
    exits 0. The test already excludes AArch64 for exactly this; RISC-V belongs
    there too, its DIV returning -1. Measured per architecture rather than taken
    from manuals: x86_64, i386, armv7l, armv6 and s390x die on signal 8; aarch64,
    ppc64le and riscv64 exit 0. Reproduced on ppc64le Linux, so this is
    architecture, not AIX.

Testing on any Linux runner

To your question on #109, @sheredom: the fork path can be exercised without AIX,
because the implementation is selected by a macro rather than by _AIX.

-DSUBPROCESS_SPAWN_VIA_FORK=1   ->  441/441 on glibc

That would have caught the build error #109 currently has, and it is cheap
enough to add to CI.

Results

Target result
AIX 7.2 TL04 on POWER8, GCC 13.3.0 441 / 441, no override beyond -maix64
Linux x86_64, glibc / musl 441 / 441
Linux i386 441 / 441
Linux, -DSUBPROCESS_SPAWN_VIA_FORK=1 441 / 441
Linux, -DSUBPROCESS_HAVE_CWD=0 -DSUBPROCESS_SPAWN_REPORTS_EXEC_ERRORS=0 441 / 441
Windows MSVC 19.44, x86 and x64, both CRTs 387 / 387
clang, AddressSanitizer and UBSan 441 / 441
NetBSD 9.4 and 10.1, OpenBSD 7.9, FreeBSD 14.3 compile clean, cross against vendor sysroots
macOS 10.13–14.0 via osxcross, real 15.5 SDK compile clean, SUBPROCESS_HAVE_CWD 0 below 10.15 and 1 from 10.15

The fail-closed row is worth calling out: those are the paths added by #104 and
#106, and as far as I can tell they had never actually been executed anywhere
before, only compiled.

Also run: clang --analyze reports nothing on either path, and a purpose-built
test spawning from eight threads — 640 spawns — passes on both paths under
AddressSanitizer, since fork() in a threaded process is the risky part and the
suite is single-threaded.

Not verified: the macOS 26 branch of SUBPROCESS_ADDCHDIR_IS_POSIX. No publicly
mirrored SDK goes past 15.5.

The utest.h dependency is resolved

The test suite could not build on AIX until utest.h gained an AIX branch, and
it did not build 32-bit at all, which is GCC's default there. Both are now
merged upstreamsheredom/utest.h#189 and sheredom/utest.h#188.

The vendored test/utest.h in this repository is still the older copy, so those
fixes have not reached it yet. That is a separate, one-file change and
deliberately not part of this PR.

mehendarkarprajwal and others added 4 commits August 7, 2026 03:02
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.
bernardladenthin added a commit to bernardladenthin/subprocess.h that referenced this pull request Aug 10, 2026
The first run of this workflow failed all four foreign-arch jobs, and for a
reason that has nothing to do with the architectures: qemu-user does not
propagate the errno that glibc's posix_spawn uses to report a failed exec, so
create_ex_subprocess_create_failure_preserves_error fails there regardless of
the target. s390x failed identically to ppc64le despite having no architectural
difference in play, which is what gives it away.

Running the whole suite under emulation therefore cannot go green, and a job
that is permanently red is worse than no job. These now build the whole suite —
which still covers compilation for each architecture — and run only the tests
whose outcome actually depends on the instruction set:

    ./subprocess_test '--filter=*divzero'

That is the reason the jobs exist: integer division by zero traps on x86, Arm
and s390x but not on PowerPC or RISC-V. The comment explains the narrowing so it
does not read as suppressing inconvenient failures.

Also noted in the file that the SUBPROCESS_SPAWN_VIA_FORK combination in
capability-paths is a no-op until sheredom#112 lands, so its green tick is not mistaken
for evidence today.
@bernardladenthin

bernardladenthin commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

Now confirmed on real POWER10 hardware rather than emulation.

I rented an IBM Cloud Power Virtual Server — AIX 7.3 TL04 SP01
(7300-04-01-2620) on an IBM 9105-22A, PowerPC_POWER10
— and rebuilt this
branch there. Everything I reported earlier was measured under QEMU/TCG on an
emulated POWER8, so this is the first result on actual silicon, and on a newer
AIX than the one the port was written against.

Results

Build Compile Tests
64-bit, -maix64 0 errors 441 / 441
32-bit, GCC's default on AIX 0 errors 441 / 441

GCC 13.3.0, CMake 4.2.0. No capability override of any kind — no
-DSUBPROCESS_HAVE_CWD=0, nothing beyond the word-size flag.

The blanket defined(_AIX) is correct

This was the part I was least sure about. For NetBSD the probe needs a version
test, because 10.0 gained the function under its POSIX name. AIX does not:

7.2 TL04 7.3 TL04
posix_spawn_file_actions_addchdir[_np] in spawn.h absent absent
exported from libc.a, both members absent absent
execvpe declared in unistd.h no no

So @mehendarkarprajwal's comment about execvpe still holds word for word two
releases on, and SUBPROCESS_SPAWN_VIA_FORK needs no version gate for AIX.

Divide-by-zero, third independent measurement

The architecture guard in test_shared.h now has a native data point:

Platform result exit traps
ppc64le Linux r=42 0 no
AIX 7.2 / POWER8, emulated 0 no
AIX 7.3 / POWER10, native r=0 0 no

The returned values differ between POWER8 and POWER10 while the absence of a trap
does not — which is precisely what the ISA specifies, the result being undefined
and only the lack of an exception guaranteed. A guard keyed on the value would
have been wrong.

One dependency, unchanged

utest.h still stops at #error Unsupported platform! on 7.3, so both rows
above need sheredom/utest.h#189 — without it nothing builds on AIX at all. The
32-bit row additionally needs sheredom/utest.h#188, since AIX is rejected before
its 32-bit problems are even reached. Verified one variant at a time on this
machine. The vendored copy in this repository is deliberately untouched.

Happy to run anything else you would like checked while I have the machine.

@bernardladenthin

Copy link
Copy Markdown
Contributor Author

Three more checks on the same POWER10 machine, each covering something emulation
could not settle.

Negative control

The results so far show the branch works. They do not, on their own, show why
a branch can pass for the wrong reason. Forcing the probe the wrong way round
fails at exactly the symbol the probe exists for:

$ gcc -maix64 -DSUBPROCESS_SPAWN_VIA_FORK=0 -I. neg.c -o neg
subprocess.h:1453:19: warning: implicit declaration of function
  'posix_spawn_file_actions_addchdir_np'; did you mean 'posix_spawn_file_actions_addopen'?
ld: 0711-317 ERROR: Undefined symbol: .posix_spawn_file_actions_addchdir_np

$ gcc -maix64 -I. neg.c -o neg          # default, probe left alone
(builds)

The probes resolve as intended here, checked by printing them rather than reading
the source:

SUBPROCESS_SPAWN_VIA_FORK            = 1
SUBPROCESS_ADDCHDIR_IS_POSIX         = 0
SUBPROCESS_HAVE_CWD                  = 1
SUBPROCESS_SPAWN_REPORTS_EXEC_ERRORS = 1

fork() from threads

fork() in a threaded process is the risky part of this implementation — between
fork and exec only async-signal-safe calls are allowed — and the suite is
single-threaded, so it never exercises that. A purpose-built test spawning from
eight threads, 640 spawns with half of them deliberately failing execs, passes on
POWER10. It also passes against the posix_spawn path on glibc, so the two are
comparable.

Repeatability

Five consecutive runs of the full suite, all 441/441, exit 0. Worth stating
because a single green run says less than it looks: while testing this work under
Docker, the same AddressSanitizer binary passed, segfaulted and passed again
across three runs of one container. On real hardware there is no such variance.

@mehendarkarprajwal

Copy link
Copy Markdown
Contributor

Thank youu @bernardladenthin for all the effort

Comment thread subprocess.h Outdated
#include <unistd.h>
#endif

/* __NetBSD_Version__ lives in <sys/param.h>, not in the compiler's predefines,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is verbose and not interesting, just remove it.

Comment thread subprocess.h Outdated
#endif
#endif

/* Whether to launch the child with fork()+exec() instead of posix_spawn().

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make this comment more succinct please.

Comment thread subprocess.h Outdated
XCU 2.8.2), and the value glibc's posix_spawn used before it learned to
report exec failures. The real reason travels over the error pipe; this is
only what a caller sees if that write is lost. */
#if !defined(SUBPROCESS_EXEC_FAILURE_STATUS)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we even need this define?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed

Comment thread subprocess.h Outdated
#endif
if (subprocess_option_search_user_path ==
(options & subprocess_option_search_user_path)) {
/* AIX exports execvpe from libc but declares it in no header. */

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we only extern to this in a define block for AIX above the function instead then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it above the function, but guarded on SUBPROCESS_SPAWN_VIA_FORK
rather than _AIX.

Narrowing it to AIX breaks glibc: execvpe is a GNU extension there, so
<unistd.h> only declares it when _GNU_SOURCE is set, and anyone forcing
the fork path without that macro loses the declaration. Measured both ways
with -Werror=implicit-function-declaration — AIX-only fails without
_GNU_SOURCE and builds with it. The suite can't see either way, since
test/CMakeLists.txt defines _GNU_SOURCE on Linux. musl hides it the same
way.

The comment is no longer AIX-specific either: OpenBSD and NetBSD reach this
code too, and both do have execvpe.

bernardladenthin added a commit to bernardladenthin/subprocess.h that referenced this pull request Aug 14, 2026
Reviewer feedback on sheredom#113: the file carried explanatory prose that repeated
what the job and matrix names state. The paragraphs above capability-paths,
linux-32-bit, musl and macos-deployment-target are gone entirely — "no chdir
file action, no exec error reporting" and "macOS deployment target 10.14"
already say it.

Two survive as single lines, both where deleting the comment would invite a
wrong edit rather than merely lose background:

  foreign-arch  why it runs only *divzero. Without it the narrowing looks
                like an oversight and the obvious "fix" is a permanently
                red job.
  bsd           why NetBSD is listed twice, so the duplicate does not get
                tidied away and take the 9.x path with it.

The note that SUBPROCESS_SPAWN_VIA_FORK is a no-op until sheredom#112 lands is
dropped as well. It was accurate when written but describes a moment rather
than the workflow, and it goes stale the day sheredom#112 merges.

No job, step or matrix entry is touched: 7 jobs, 17 runs, before and after.
The reasoning that left the file is preserved in the two commits that added
it and in the PR description.
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.
@bernardladenthin

Copy link
Copy Markdown
Contributor Author

Merged current main. That surfaced an interaction worth flagging.

#115 now creates the stdio pipes close-on-exec, and the forked child relies on
dup2 to strip that flag when it installs them on 0, 1 and 2. dup2 does —
except dup2(fd, fd), which is a no-op. With fd 0 free in the parent, the
stdin pipe's read end lands on 0 and the child execs without a stdin.

Neither change causes it on its own:

fd 0 open fd 0 closed
this branch before the merge ok ok
main ok ok
merged ok child loses stdin

Fixed by clearing FD_CLOEXEC on the three descriptors after the dup2s, with
a test that closes fd 0 around a subprocess_create and checks the child
still has one — it uses the process_is_fd_open helper #115 brought along.

Worth noting the suite could not have caught this: it stayed at 442/442 in
both implementations, before and after, because nothing ran with a standard
descriptor closed. It is 443/443 now.

@sheredom

Copy link
Copy Markdown
Owner

Ok I've merged that dependent PR. You might want to rebase and repush!

subprocess_fds_above_std, added in sheredom#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 sheredom#118.
@bernardladenthin

Copy link
Copy Markdown
Contributor Author

Brought up to date with current main and pushed.

One deviation from your wording: I merged main in rather than rebasing.
git rebase conflicts on the first commit, e6cec1b — and rewriting that one
is the real problem, since it is @mehendarkarprajwal's from #109 and carried
here unchanged on purpose. The merge applies cleanly and keeps the attribution
intact.

Also dropped the FD_CLOEXEC clearing, as promised in #118. subprocess_fds_above_std
now moves every pipe end above STDERR_FILENO on both pipe-creation paths, so
dup2 in the child can no longer be a no-op on a standard descriptor. The
regression test from that commit stays — it still passes, and it fails again if
the new protection is disabled, so it is still testing something.

fd-zero test on the fork path
block dropped, fds_above_std present passes
block dropped, fds_above_std neutralised failsexec closed the child's stdin

Full suite, gcc 13, x86-64: 444/444 with posix_spawn and 444/444 with
-DSUBPROCESS_SPAWN_VIA_FORK=1.

sheredom/utest.h#188, #189 and #190 are all merged now, so the dependency note
in the description is stale — the vendored test/utest.h in this repository is
still the old copy, which is a separate change and not part of this PR.

@sheredom
sheredom merged commit 0dccaa9 into sheredom:main Aug 19, 2026
24 checks passed
bernardladenthin added a commit to bernardladenthin/subprocess.h that referenced this pull request Aug 19, 2026
<sys/param.h>, included here since sheredom#112 for __NetBSD_Version__, pulls in
<sys/inttypes.h>. That header gates <machine/int_fmtio.h> on
__STDC_FORMAT_MACROS for C++ before C++11 and sits behind an include guard,
so anything defining the macro afterwards cannot get PRId64 and PRIu64 back.
Consumers including subprocess.h first lost them silently; the test suite's
C++98 translation unit is one.
sheredom pushed a commit that referenced this pull request Aug 19, 2026
* Add a portability workflow beside the existing matrix

cmake.yml covers x86-64 Linux, Windows and macOS across four build types and
four compilers, and it covers them well. What it cannot reach is anything that
is not selected by the runner it happens to be on: the capability probes only
ever resolve one way per machine, every runner is 64-bit, and no BSD or non-x86
architecture is present at all.

The new workflow adds one job per dimension:

  capability-paths          force each combination of SUBPROCESS_HAVE_CWD,
                            SUBPROCESS_SPAWN_REPORTS_EXEC_ERRORS and
                            SUBPROCESS_SPAWN_VIA_FORK on an ordinary runner
  linux-32-bit, windows-x86 ILP32, where uint64_t becomes unsigned long long
  musl                      a second libc
  foreign-arch              ppc64le, riscv64, s390x, armv7 under qemu-user
  bsd                       real FreeBSD, NetBSD 9.4 and 10.1, OpenBSD VMs
  macos-deployment-target   10.14, 10.15 and 11.0, either side of the boundary
                            that SUBPROCESS_HAVE_CWD turns on

capability-paths is the cheapest and catches the most. The library selects its
implementation at compile time, so on any given runner the other paths are never
executed — including the ones added by #104 and #106, which as far as I can tell
had never actually run anywhere before, only compiled.

NetBSD is listed at both 9.4 and 10.1 on purpose: posix_spawn_file_actions_addchdir
arrived in 10.0 and is spelled without the _np suffix, so the two take different
paths through the header.

foreign-arch runs under qemu-user, which is faithful for instruction-set
semantics but not for posix_spawn's exec-error reporting — it does not propagate
the errno, and a create_ex failure there would be an artifact rather than a
platform property. That is noted in the file so nobody reads it the wrong way.

* Narrow the emulated-architecture jobs to what emulation reproduces

The first run of this workflow failed all four foreign-arch jobs, and for a
reason that has nothing to do with the architectures: qemu-user does not
propagate the errno that glibc's posix_spawn uses to report a failed exec, so
create_ex_subprocess_create_failure_preserves_error fails there regardless of
the target. s390x failed identically to ppc64le despite having no architectural
difference in play, which is what gives it away.

Running the whole suite under emulation therefore cannot go green, and a job
that is permanently red is worse than no job. These now build the whole suite —
which still covers compilation for each architecture — and run only the tests
whose outcome actually depends on the instruction set:

    ./subprocess_test '--filter=*divzero'

That is the reason the jobs exist: integer division by zero traps on x86, Arm
and s390x but not on PowerPC or RISC-V. The comment explains the narrowing so it
does not read as suppressing inconvenient failures.

Also noted in the file that the SUBPROCESS_SPAWN_VIA_FORK combination in
capability-paths is a no-op until #112 lands, so its green tick is not mistaken
for evidence today.

* Trim the workflow comments to what the YAML does not already say

Reviewer feedback on #113: the file carried explanatory prose that repeated
what the job and matrix names state. The paragraphs above capability-paths,
linux-32-bit, musl and macos-deployment-target are gone entirely — "no chdir
file action, no exec error reporting" and "macOS deployment target 10.14"
already say it.

Two survive as single lines, both where deleting the comment would invite a
wrong edit rather than merely lose background:

  foreign-arch  why it runs only *divzero. Without it the narrowing looks
                like an oversight and the obvious "fix" is a permanently
                red job.
  bsd           why NetBSD is listed twice, so the duplicate does not get
                tidied away and take the 9.x path with it.

The note that SUBPROCESS_SPAWN_VIA_FORK is a no-op until #112 lands is
dropped as well. It was accurate when written but describes a moment rather
than the workflow, and it goes stale the day #112 merges.

No job, step or matrix entry is touched: 7 jobs, 17 runs, before and after.
The reasoning that left the file is preserved in the two commits that added
it and in the PR description.

* Refresh the vendored utest.h to sheredom/utest.h@b6230bc

The old copy is a pristine snapshot of utest.h@f4610ee. Since then upstream
fixed the ILP32 constants and the NetBSD PRIu64 problem (#188), the test
filter dropping tests it should run (#190) and added AIX support (#189).

* Keep the PRI macros visible on NetBSD in C++98

<sys/param.h>, included here since #112 for __NetBSD_Version__, pulls in
<sys/inttypes.h>. That header gates <machine/int_fmtio.h> on
__STDC_FORMAT_MACROS for C++ before C++11 and sits behind an include guard,
so anything defining the macro afterwards cannot get PRId64 and PRIu64 back.
Consumers including subprocess.h first lost them silently; the test suite's
C++98 translation unit is one.

* Fall back to the compiler default where -std=c++2a is unknown too

#105 added -std=c++2a for GCC 8 and clang 9, which do not know -std=c++20.
GCC 7 and older know neither, so the fallback passed a flag those compilers
reject and the build stopped before running anything. NetBSD 9.4 ships GCC
7.5.0 and hit exactly this.

test20.cpp now drops the standard flag when neither spelling is available,
which is what already happens for test11.c without c_std_11 and for MSVC,
where no -std flag is passed at all. The suite then runs that translation
unit at the compiler default rather than not at all.

Measured in GCC 7.5.0: -std=c++20 and -std=c++2a are both rejected, and the
file compiles clean at the default gnu++14 under the flags CMakeLists sets.
GCC 13 still selects -std=c++20 and the suite stays at 444/444.
@bernardladenthin
bernardladenthin deleted the aix-port branch August 20, 2026 13:42
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.

3 participants