Skip to content

Report a missing executable on glibc older than 2.24 - #106

Merged
sheredom merged 5 commits into
sheredom:mainfrom
bernardladenthin:fix/spawn-exec-errors-old-glibc
Aug 9, 2026
Merged

Report a missing executable on glibc older than 2.24#106
sheredom merged 5 commits into
sheredom:mainfrom
bernardladenthin:fix/spawn-exec-errors-old-glibc

Conversation

@bernardladenthin

Copy link
Copy Markdown
Contributor

Depends on #104 — without it the library does not build on any glibc old enough to show
this problem.

Problem

On glibc older than 2.24, posix_spawn does not report a failed exec back to the
caller. subprocess_create therefore returns success for an executable that does not
exist, and the child quietly exits with 127. Two tests in the suite cover exactly this
and fail on such systems:

create_ex_subprocess_create_failure_preserves_error
create_ex_subprocess_create_failure_does_not_leak_resources

Across the 9 language modes that is 18 failures on manylinux2014 (glibc 2.17). They only
became visible with #104, because before that the library did not build there at all.

The behaviour is glibc's, not this library's — a standalone probe with a missing binary:

glibc posix_spawn
2.17 rc=0, child exits 127
2.23 rc=0, child exits 127
2.24 rc=2 (ENOENT)
2.28 rc=2 (ENOENT)

Fix

Add a SUBPROCESS_SPAWN_REPORTS_EXEC_ERRORS probe alongside SUBPROCESS_HAVE_CWD, and
where it is 0, check the executable with access(..., X_OK) before spawning. errno and
the returned subprocess_error_e then match what every supported glibc produces.

Behaviour on glibc 2.24 and newer, and on every non-glibc platform, is unchanged — the
check is not compiled there at all.

Limitations

Only the explicit-path branch is covered. posix_spawnp resolves the executable
through PATH, which would mean reimplementing that search — including empty entries
meaning "current directory" and the ENOEXEC shell fallback. Measured on glibc 2.17 with
this patch applied:

posix_spawn  (explicit path): rc=-4  correct
posix_spawnp (PATH search)  : rc= 0  still unreported

Nobody is worse off than before, where both cases were silent, but the PATH case remains
wrong on those systems. Happy to extend it if you would rather have the full search.

There is a TOCTOU window between access and posix_spawn. On a platform whose
posix_spawn cannot report the failure at all, a best-effort check seemed better than
none — but it is a best-effort check, not a guarantee.

Testing

Environment libc Compiler Result
manylinux2014 glibc 2.17 GCC 10.2 423 pass (405 pass / 18 fail before)
AlmaLinux 8 glibc 2.28 clang 21.1 423 pass, unchanged
Debian 11 glibc 2.31 GCC 10.2 432 pass, unchanged
Ubuntu 24.04 glibc 2.39 GCC 13.3 432 pass, unchanged
Ubuntu 24.04 glibc 2.39 clang 18.1 432 pass, unchanged
Alpine 3.20 musl 1.2 GCC 13.2 432 pass, unchanged
Windows UCRT MSVC 19.44 387 pass, unchanged

AlmaLinux 8 is the interesting control: glibc 2.28 is old enough to lack
addchdir_np but new enough to report exec failures, so the probe must be 0 for one
feature and 1 for the other — and nothing there changes.

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.
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.
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.
@sheredom
sheredom merged commit f455031 into sheredom:main Aug 9, 2026
24 checks passed
@bernardladenthin
bernardladenthin deleted the fix/spawn-exec-errors-old-glibc branch August 9, 2026 13:35
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.
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.

2 participants