Skip to content

fix(subprocess): spawn instead of fork+exec on macOS - #1475

Open
DeusData wants to merge 1 commit into
mainfrom
fix/subprocess-posix-spawn
Open

fix(subprocess): spawn instead of fork+exec on macOS#1475
DeusData wants to merge 1 commit into
mainfrom
fix/subprocess-posix-spawn

Conversation

@DeusData

@DeusData DeusData commented Aug 6, 2026

Copy link
Copy Markdown
Owner

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 before exec replaces the image. The reaped corpse then looks like the launched tool dying — so a git init inside 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.c already switched to posix_spawn for this exact reason. This moves the shared subprocess layer over as well, so the whole codebase stops inheriting the hazard.

Approach

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 explicitly:

guarantee fork+exec posix_spawn
own process group (kill-tree contract) setpgid(0,0) SETPGROUP + setpgroup(0)
default signal dispositions, empty mask manual reset in child SETSIGDEF + SETSIGMASK
std{in,out,err} wired to caller fds dup2 in child adddup2 file actions
every other descriptor closed close-everything loop CLOEXEC_DEFAULT
PATH lookup for a bare tool name execvp posix_spawnp

CLOEXEC_DEFAULT is Apple's equivalent of the child's close-everything loop; the three dup2'd descriptors survive it because dup2 clears close-on-exec.

The one behavioural difference, reconciled rather than adopted

posix_spawn reports an unusable binary to the parent, where fork+exec instead produces a child that exits 127. cbm_subprocess_run's contract — asserted by subprocess_run_spawn_failure — treats spawn_failed as "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 to fork+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 daemon47 passed, 0 failed. make lint-ci clean.

No new test ships here because the existing suite already binds every guarantee — confirmed by breaking each one and watching the RED appear:

  • removing POSIX_SPAWN_CLOEXEC_DEFAULT leaks the sentinel descriptor into the child and reddens subprocess_posix_child_closes_unrelated_descriptors (result.outcome == 1, expected CBM_PROC_CLEAN)
  • removing POSIX_SPAWN_SETPGROUP makes the kill-tree logic turn on the runner's own process group, reddening the suite wholesale
  • the 127-classification contract is bound by subprocess_run_spawn_failure, which caught the semantic difference above during development
  • PATH resolution is bound by subprocess_run_resolves_literal_binary_name_from_path

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
DeusData enabled auto-merge August 6, 2026 16: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.

1 participant