fix(subprocess): spawn instead of fork+exec on macOS - #1475
Open
DeusData wants to merge 1 commit into
Open
Conversation
fork() duplicates the parent's address-space bookkeeping. Under an
ASan-instrumented parent that includes an enormous shadow mapping, and past a
footprint threshold the child is jetsam-killed BEFORE exec replaces the image.
The reaped corpse then looks like the launched tool dying, so a `git init` in a
fixture "fails" only once enough suites have run ahead of it — a spawn defect
that reads as an unrelated assertion. tests/test_daemon_runtime.c already
switched to posix_spawn for this exact reason; this moves the shared
subprocess layer over as well.
posix_spawn never copies the parent address space, so the parent's footprint
stops being a variable. Every guarantee of the fork path is carried over:
- own process group SETPGROUP + setpgroup(0) (kill-tree contract)
- default signal handling SETSIGDEF + SETSIGMASK
- std{in,out,err} wiring adddup2
- all other fds closed CLOEXEC_DEFAULT, Apple's equivalent of the
child's close-everything loop (the three dup2'd
fds survive, since dup2 clears close-on-exec)
- PATH lookup posix_spawnp keeps execvp semantics
One deliberate difference is reconciled rather than adopted: posix_spawn
reports an unusable binary to the PARENT, where fork+exec instead yields a
child that exits 127. cbm_subprocess_run's contract treats spawn_failed as
"the spawn mechanism failed", not "the tool was missing", so exec-class errors
fall back to fork+exec and macOS keeps classifying a bogus binary exactly as
Linux does. That path forks a child that exits immediately, so it does not
reintroduce the hazard.
The existing suite binds each guarantee, verified by breaking them:
dropping CLOEXEC_DEFAULT leaks the sentinel descriptor and reddens
subprocess_posix_child_closes_unrelated_descriptors; dropping SETPGROUP makes
the kill-tree turn on the runner's own group and reddens the suite wholesale.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
DeusData
enabled auto-merge
August 6, 2026 16:42
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
fork()duplicates the parent's address-space bookkeeping. Under an ASan-instrumented parent that includes an enormous shadow mapping, and past a footprint threshold the child is jetsam-killed beforeexecreplaces the image. The reaped corpse then looks like the launched tool dying — so agit initinside a fixture "fails" only once enough suites have run ahead of it, and the symptom surfaces as an unrelated-looking assertion rather than a spawn defect.tests/test_daemon_runtime.calready switched toposix_spawnfor this exact reason. This moves the shared subprocess layer over as well, so the whole codebase stops inheriting the hazard.Approach
posix_spawnnever copies the parent address space, so the parent's footprint stops being a variable. Every guarantee of the fork path is carried over explicitly:setpgid(0,0)SETPGROUP+setpgroup(0)SETSIGDEF+SETSIGMASKstd{in,out,err}wired to caller fdsdup2in childadddup2file actionsCLOEXEC_DEFAULTPATHlookup for a bare tool nameexecvpposix_spawnpCLOEXEC_DEFAULTis Apple's equivalent of the child's close-everything loop; the threedup2'd descriptors survive it becausedup2clears close-on-exec.The one behavioural difference, reconciled rather than adopted
posix_spawnreports an unusable binary to the parent, wherefork+execinstead produces a child that exits 127.cbm_subprocess_run's contract — asserted bysubprocess_run_spawn_failure— treatsspawn_failedas "the spawn mechanism failed", not "the tool was missing".Adopting the new semantics would have made macOS classify a missing binary differently from Linux. Instead, exec-class errors (
ENOENT,EACCES,ENOEXEC,EISDIR,ELOOP,ENAMETOOLONG,ENOTDIR) fall back tofork+exec, so both platforms classify a bogus binary identically. That fallback child exits immediately, so it does not reintroduce the footprint hazard.Verification
macOS
subprocess index_supervisor daemon— 47 passed, 0 failed.make lint-ciclean.No new test ships here because the existing suite already binds every guarantee — confirmed by breaking each one and watching the RED appear:
POSIX_SPAWN_CLOEXEC_DEFAULTleaks the sentinel descriptor into the child and reddenssubprocess_posix_child_closes_unrelated_descriptors(result.outcome == 1, expectedCBM_PROC_CLEAN)POSIX_SPAWN_SETPGROUPmakes the kill-tree logic turn on the runner's own process group, reddening the suite wholesalesubprocess_run_spawn_failure, which caught the semantic difference above during developmentPATHresolution is bound bysubprocess_run_resolves_literal_binary_name_from_path