Skip to content

fix(child_process): spawn via posix_spawn to avoid a macOS fork/dyld deadlock - #7157

Merged
proggeramlug merged 2 commits into
PerryTS:mainfrom
jdalton:fix/macos-child-process-posix-spawn
Aug 1, 2026
Merged

fix(child_process): spawn via posix_spawn to avoid a macOS fork/dyld deadlock#7157
proggeramlug merged 2 commits into
PerryTS:mainfrom
jdalton:fix/macos-child-process-posix-spawn

Conversation

@jdalton

@jdalton jdalton commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

On macOS, a Perry program that starts a child process with child_process (exec, spawn, or execFile) can hang forever instead of running the command. Even something as trivial as sh -c "echo hi" never gets as far as executing echo. The parent then waits on a child that will never finish, so the whole program stalls. This affects any macOS program that shells out, and it shows up under garbage-collection pressure because that is when the runtime has the most threads alive. Two real programs hit it: one reads a machine identifier at startup by shelling out through node-machine-id, and another spawns go, mvn, and dotnet from a tool-execution code path.

The fix is to resolve a bare command name such as sh to its absolute path (/bin/sh) before handing it to the operating system. That one change keeps Rust's standard library on the posix_spawn code path instead of the fork code path, and fork is what deadlocks. Linux is unaffected either way. The original command name is still what the child sees as its own argv[0], and if nothing resolves, the bare name is passed through unchanged.

What the hang looks like from the outside

The child process gets as far as exec and then stops inside dyld, the macOS dynamic linker, on this stack:

_dyld_start
  → dyld4::prepare
    → RemoteNotificationResponder::blockOnSynchronousEvent
      → mach_msg2_trap

blockOnSynchronousEvent is dyld telling any attached observer that a new image is loading, and waiting for an acknowledgement that never arrives. With the child stuck there, Perry's reader and waiter threads block forever in read() and wait4() respectively, and the main loop sits idle in js_wait_for_event.

Why the standard library falls back to fork

There are two ways to start a child process on Unix. posix_spawn is a single operating-system call that creates the new process and loads the new program in one step. The fork plus exec pair first clones the current process, then replaces the clone's program image. Rust's std::process::Command prefers posix_spawn but silently falls back to fork plus exec when it cannot express what was asked of it.

The relevant condition lives in library/std/src/sys/pal/unix/process/process_unix.rs, where posix_spawn is chosen only when the program is given as a path and no pre_exec, uid, or gid closures are set. The specific guard is env_saw_path() && !program_is_path(). Passing an env option triggers env_clear(), which sets env_saw_path(), so a bare command name combined with an env option lands on the fallback.

fork is the unsafe half. Perry's runtime is multithreaded, with an async reactor plus garbage-collector and worker threads. fork only copies the calling thread, but the child inherits all the locks and Mach state those other threads were holding, and those threads no longer exist to release anything. dyld's synchronous notification in the child then waits on state that can never be handed over, which is the deadlock.

Syscall-level before and after

Confirmed by interposing on dyld with DYLD_INSERT_LIBRARIES to record which spawn mechanism the standard library actually chose:

case before after
exec('echo hi', { env }) in a Promise fork() posix_spawn
spawnSync('sh', …, { env }) fork() posix_spawn
exec('echo hi') (no opts) posix_spawn posix_spawn

Output capture, exit codes, argv, and distinct child process identifiers are unchanged across all three.

How the resolution works

Before building the Command, a bare program name is resolved against the child's effective PATH. That means the PATH from the env option when one is present, which matches what Node and execvp do, and the parent's PATH otherwise. The resolved absolute path is what the standard library receives, so its program_is_path() check passes and it stays on posix_spawn even when an env or cwd option is present.

The original name is preserved as argv[0] via arg0, so a child that inspects its own name sees what the caller wrote. If nothing resolves, the bare name is passed through unchanged; a genuine ENOENT never executes a real program image, so it cannot reach the dyld hang.

exec, execSync, and promisify(exec) now use the absolute /bin/sh, which is the shell Node uses. spawn, spawnSync, execFile, execFileSync, promisify(execFile), and spawn_background resolve their program through cp_command_for_program.

What still uses fork, and why

Three cases necessarily stay on the standard library's fork path, because posix_spawn cannot express them through Rust's wrapper: detached (which needs setsid), the IPC dup2 that fork() performs, and setting a uid or gid. Each is documented inline at its call site. None of them is part of the reported exec/spawn impact.

Verification runs

The dyld interposer shows the env-carrying exec and spawnSync cases flipping from fork() to posix_spawn, with output capture, exit codes, argv, and distinct child process identifiers unchanged.

Recompiled reproductions run to completion under PERRY_GC_FORCE_EVACUATE=1: the exec-in-a-Promise case passes 25 out of 25 runs and the spawnSync case passes 15 out of 15.

cargo test -p perry-runtime child_process reports 7 of 7 passing, including 4 new regression tests that assert the resolved program is an absolute path and that it spawns and captures output correctly.

Linux behavior is unchanged, since the resolver only rewrites bare names to absolute paths, posix_spawn is already the norm there, and the fork hazard is macOS-specific.

One caveat on the evidence. The hard deadlock only reproduces when a dyld notification observer is attached, such as a debugger, a sampler, or a product's telemetry hook, and no such observer exists in a bare shell. This change is therefore verified at the syscall level, showing the switch from fork to posix_spawn, plus functional and behavioral equivalence, rather than by reproducing the hang itself in a bare shell.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed macOS child-process deadlocks affecting shell and executable launches.
    • Improved command resolution using the child process’s effective PATH.
    • Preserved argv[0] while resolving bare commands to reliable executable paths.
    • Improved background process environment handling, including fallback PATH behavior.
    • Retained existing behavior for path-qualified commands and unresolved executables.
  • Documentation

    • Added changelog details covering the macOS fix, affected APIs, verification, and remaining limitations.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 304a85d6-233b-4fdd-8058-3f78f1cb1453

📥 Commits

Reviewing files that changed from the base of the PR and between 55737ce and 2885a0c.

📒 Files selected for processing (3)
  • crates/perry-runtime/src/child_process/exec.rs
  • crates/perry-runtime/src/child_process/mod.rs
  • crates/perry-runtime/src/child_process/options.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/perry-runtime/src/child_process/exec.rs
  • crates/perry-runtime/src/child_process/mod.rs
  • crates/perry-runtime/src/child_process/options.rs

📝 Walkthrough

Walkthrough

Unix child-process execution now resolves bare commands to absolute paths and uses /bin/sh for shell execution. Background spawning applies the effective child PATH before command construction while preserving argv[0] and existing fallback behavior.

Changes

Unix child-process resolution

Layer / File(s) Summary
Executable path resolution and command construction
crates/perry-runtime/src/child_process/options.rs, crates/perry-runtime/src/child_process/mod.rs
Command helpers select the effective Unix PATH, resolve bare executable names, preserve argv[0], and retain fallback behavior. Unix tests cover resolution and execution cases.
Exec wrapper integration
crates/perry-runtime/src/child_process/exec.rs, changelog.d/7157-macos-child-process-posix-spawn.md
Unix shell execution uses /bin/sh. Direct and promisified execFile variants use the shared command-resolution helper. The changelog documents the macOS behavior and remaining fork paths.
Background process spawning
crates/perry-runtime/src/child_process/registry.rs
Environment JSON is parsed before command construction. Bare commands use the environment-provided or inherited PATH, and string-valued environment entries are applied afterward.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChildProcessAPI
  participant cp_command_for_program
  participant cp_resolve_program_path
  participant Command
  ChildProcessAPI->>cp_command_for_program: provide program and options
  cp_command_for_program->>cp_resolve_program_path: resolve bare program with effective PATH
  cp_resolve_program_path-->>cp_command_for_program: absolute path or fallback
  cp_command_for_program->>Command: construct command and preserve argv[0]
  Command-->>ChildProcessAPI: execute child process
Loading

Possibly related PRs

Suggested labels: bug, rust

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the macOS child-process deadlock fix and the use of posix_spawn.
Description check ✅ Passed The description clearly explains the problem, implementation, affected APIs, limitations, and verification results, although it omits the template headings and checklist.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…deadlock

`std::process::Command` only uses `posix_spawn` when the program is given as a
path and no `pre_exec`/uid/gid closures are set; a bare command name combined
with an `env` option (which calls `env_clear()`) drops it onto the `fork`+`exec`
fallback. On macOS a `fork` from Perry's multithreaded runtime (async reactor +
GC/worker threads) can deadlock the child post-`exec` in dyld
(`RemoteNotificationResponder::blockOnSynchronousEvent`): the child inherits
locks/Mach state from parent threads that no longer exist after `fork`. The
reader/waiter threads then block forever in `read()`/`wait4()` and the main loop
idles in `js_wait_for_event`.

Resolve a bare command name to its absolute path in the child's effective PATH
before building the `Command`, so std stays on the `posix_spawn` fast path even
when an `env`/`cwd` option is present; `argv[0]` is preserved via `arg0`. The
`exec`/`execSync`/promisify(exec) shell now uses the absolute `/bin/sh` (Node's
shell), and `spawn`/`spawnSync`/`execFile`/`execFileSync`/spawn_background all
resolve their program. Verified with a dyld interposer: `exec`/`spawnSync` with
an `env` option go from `fork()` to `posix_spawn` while stdout/exit-code capture
and argv are unchanged.

`detached` (setsid), `fork()`'s IPC dup2, and uid/gid necessarily keep std's
fork path — `posix_spawn` can't express them via std — and are documented as
such; they are outside the reported exec/spawn impact.
@jdalton
jdalton force-pushed the fix/macos-child-process-posix-spawn branch from 43fe6bc to 55737ce Compare August 1, 2026 02:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/perry-runtime/src/child_process/exec.rs (1)

49-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reduce triplication of the hardcoded /bin/sh shell path.

js_child_process_exec_sync, js_child_process_exec, and cp_promisified_exec each hardcode Command::new("/bin/sh") with a near-identical explanatory comment. cp_default_shell() in options.rs already returns /bin/sh for non-Windows targets. Reuse it here. This keeps the shell-path decision in one place if it ever needs to change.

♻️ Proposed refactor to reuse `cp_default_shell()`
-    #[cfg(unix)]
-    let mut command = {
-        // Absolute path (Node's `exec` shell) keeps std on `posix_spawn`
-        // instead of the `fork`+`exec` fallback a bare "sh" + `env` triggers
-        // (the macOS fork/dyld deadlock fix — see `cp_command_for_program`).
-        let mut c = Command::new("/bin/sh");
+    #[cfg(unix)]
+    let mut command = {
+        // Already-absolute (Node's `exec` shell) keeps std on `posix_spawn`
+        // instead of the `fork`+`exec` fallback a bare "sh" + `env` triggers
+        // (the macOS fork/dyld deadlock fix — see `cp_command_for_program`).
+        let mut c = Command::new(cp_default_shell());
         c.arg("-c").arg(&cmd_str);
         c
     };

Repeat the same substitution at the other two occurrences. Requires making cp_default_shell pub(crate) in options.rs and re-exporting it from mod.rs:

pub(crate) fn cp_default_shell() -> String { ... }
pub(crate) use options::{..., cp_default_shell, ...};

Also applies to: 284-292, 483-491

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/child_process/exec.rs` around lines 49 - 55,
Centralize the shell path by making cp_default_shell available as pub(crate)
from options.rs and re-exporting it in mod.rs, then replace the hardcoded
Command::new("/bin/sh") calls in js_child_process_exec_sync,
js_child_process_exec, and cp_promisified_exec with the shared default-shell
value while preserving their existing arguments and behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/child_process/registry.rs`:
- Around line 88-136: Update the PATH selection in the command-resolution block
around cp_resolve_program_path to match cp_effective_path: when env_map is
present, use its child PATH value if available without falling back to the
parent environment; when env_map is absent, use CP_DEFAULT_PATH. Remove the
current parent-PATH and empty-string fallbacks so missing PATH never resolves
against the current directory.

---

Nitpick comments:
In `@crates/perry-runtime/src/child_process/exec.rs`:
- Around line 49-55: Centralize the shell path by making cp_default_shell
available as pub(crate) from options.rs and re-exporting it in mod.rs, then
replace the hardcoded Command::new("/bin/sh") calls in
js_child_process_exec_sync, js_child_process_exec, and cp_promisified_exec with
the shared default-shell value while preserving their existing arguments and
behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f46e58f6-7187-4eca-8aa8-f50435c6ba4f

📥 Commits

Reviewing files that changed from the base of the PR and between a3b31c0 and 55737ce.

📒 Files selected for processing (5)
  • changelog.d/7157-macos-child-process-posix-spawn.md
  • crates/perry-runtime/src/child_process/exec.rs
  • crates/perry-runtime/src/child_process/mod.rs
  • crates/perry-runtime/src/child_process/options.rs
  • crates/perry-runtime/src/child_process/registry.rs

Comment on lines +88 to +136
// Parse the env JSON up front so we can read its `PATH` for command
// resolution below (an env override with a `PATH` key is what pushes
// std onto the `fork`+`exec` fallback for a bare command name).
let env_map = {
let env_bits = env_json_val.to_bits();
if env_bits != TAG_NULL_BITS && env_bits != TAG_UNDEFINED_BITS {
extract_string_from_nanboxed(env_json_val).and_then(|env_json| {
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&env_json)
.ok()
})
} else {
None
}
};

// Resolve a bare command name to an absolute path so std uses
// `posix_spawn` instead of the `fork`+`exec` fallback that an env
// override triggers — the macOS fork/dyld deadlock fix (see
// `options::cp_command_for_program`). `arg0` preserves argv[0].
let mut command = {
#[cfg(unix)]
{
let resolved = if cmd_str.contains('/') {
None
} else {
let path = env_map
.as_ref()
.and_then(|m| m.get("PATH"))
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| std::env::var("PATH").ok())
.unwrap_or_default();
super::cp_resolve_program_path(&cmd_str, &path)
};
match resolved {
Some(abs) => {
use std::os::unix::process::CommandExt;
let mut c = Command::new(abs);
c.arg0(&cmd_str);
c
}
None => Command::new(&cmd_str),
}
}
#[cfg(not(unix))]
{
Command::new(&cmd_str)
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## locate relevant files"
fd -a 'options\.rs|registry\.rs|mod\.rs' crates/perry-runtime/src/child_process 2>/dev/null || true

echo
echo "## registry.rs outline and relevant lines"
for f in $(fd 'registry\.rs' crates/perry-runtime/src/child_process); do
  echo "--- $f"
  wc -l "$f"
  sed -n '1,190p' "$f"
done

echo
echo "## options.rs relevant symbols"
for f in $(fd 'options\.rs' crates/perry-runtime/src/child_process); do
  echo "--- $f"
  wc -l "$f"
  rg -n "CP_DEFAULT_PATH|cp_effective_path|cp_resolve_program_path|PATH|env" "$f" -C 4
done

echo
echo "## child_process mod exports"
for f in $(fd 'mod\.rs' crates/perry-runtime/src/child_process); do
  echo "--- $f"
  wc -l "$f"
  sed -n '1,220p' "$f"
done

echo
echo "## tests/usages for cp_effective_path and resolve"
rg -n "cp_effective_path|CP_DEFAULT_PATH|cp_resolve_program_path|contains\('/'\)" crates/perry-runtime/src crates/perry-tests || true

Repository: PerryTS/perry

Length of output: 25634


🌐 Web query:

Rust std::process::Command posix_spawn fork exec PATH env_clear execvp documentation

💡 Result:

On Unix-like platforms, Rust's std::process::Command uses a combination of posix_spawn and the traditional fork/exec pattern to launch child processes [1][2][3]. 1. Strategy Selection: Rust preferentially uses posix_spawn for process creation because it can be more efficient and avoids some complexities associated with memory management after a fork (e.g., thread synchronization) [1][2][4]. However, it falls back to the fork/exec pattern if the configuration requires operations not easily supported by posix_spawn, such as pre-exec closures (via pre_exec), setting specific UIDs/GIDs, or when the environment's PATH has been modified (depending on the target) [3][5][6]. 2. PATH and Environment: If a program is not specified as an absolute path, the system must search for the executable. When using the traditional fork/exec path, Rust uses execvp, which automatically searches the PATH environment variable [7][8]. If env_clear or env_remove is used to remove the PATH variable without providing a replacement, execvp will typically fall back to an OS-defined default (often /bin:/usr/bin), rather than defaulting to the parent process's PATH [7][8]. 3. posix_spawn and Environment: posix_spawn generally does not perform PATH resolution in the same way as execvp (unless posix_spawnp is specifically used, which has inconsistent platform availability and behavior) [4]. Consequently, if Rust detects that the environment's PATH has been altered or that complex path-related logic is needed, it may disqualify the posix_spawn path in favor of fork/exec to ensure consistent, predictable executable resolution [3][4][5]. 4. env_clear: This method prevents the child process from inheriting any environment variables from the parent [7][9]. While this effectively clears the environment, users should be aware that if the PATH is not explicitly re-added, the system's ability to locate executables will rely on the default behavior of the underlying exec function (e.g., execvp's fallback to system defaults) rather than the parent's original environment [7][8].

Citations:


🌐 Web query:

POSIX execvp empty PATH element current directory semantics PATH missing

💡 Result:

Under the POSIX standard, if the PATH environment variable is not present, the behavior of execvp regarding the search path is explicitly implementation-defined [1][2][3]. Consequently, there is no single "POSIX standard" behavior for what happens when PATH is missing; different systems handle this differently [4][5]. Regarding the interpretation of PATH elements: 1. Empty PATH Elements: POSIX specifies that a zero-length (empty) prefix in the PATH variable shall be treated as the current working directory [6]. This is commonly represented by a leading colon, a trailing colon, or two adjacent colons in the PATH string (e.g., PATH=:/bin or PATH=/bin::/usr/bin) [6]. 2. Missing PATH Variable: On many Linux systems (specifically those using glibc), if the PATH variable is absent, the system defaults to a search path that typically includes the current working directory followed by the directories defined by confstr(_CS_PATH) [4][7][6]. Historically, this often placed the current directory at the beginning of the search path [4][5]. However, this behavior can vary; for instance, some modern implementations have opted to drop the current directory from this default search path to mitigate security risks associated with potential Trojan horses [4][7]. In summary, while POSIX mandates that empty elements in a present PATH variable denote the current directory, it leaves the default behavior entirely to the implementation when the PATH variable itself is missing [1][2][3]. Users should consult the specific documentation for their operating system or C library (e.g., glibc) to determine the exact search order employed [4][7].

Citations:


Align background-spawn PATH lookup with cp_effective_path.

cp_effective_path documents replacement-mode env lookup as child PATH or the execvp default only, with CP_DEFAULT_PATH as that default. This resolver falls back to the parent’s PATH when an env object has no "PATH", then uses an empty fallback when PATH is absent from both, which cp_resolve_program_path interprets as the current directory. Match the existing resolved Command path: use the child’s PATH when the env map is present, otherwise CP_DEFAULT_PATH.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/child_process/registry.rs` around lines 88 - 136,
Update the PATH selection in the command-resolution block around
cp_resolve_program_path to match cp_effective_path: when env_map is present, use
its child PATH value if available without falling back to the parent
environment; when env_map is absent, use CP_DEFAULT_PATH. Remove the current
parent-PATH and empty-string fallbacks so missing PATH never resolves against
the current directory.

Single conflict, in the `options` re-export list in
crates/perry-runtime/src/child_process/mod.rs: this branch adds
`cp_command_for_program` while main's Node-26 child_process parity series
added `cp_stdio_stream_fd`. Resolved as the union of both names.

The rest of the child_process series (exec.rs, options.rs, registry.rs)
merged cleanly; all of this branch's `cp_command_for_program` /
`cp_resolve_program_path` call sites survive. main's new
`spawn_detached_command` keeps `Command::new` deliberately — it installs a
`pre_exec` setsid closure, which forces std's fork path regardless, and is
the same `detached` carve-out this branch already documents.
@proggeramlug
proggeramlug merged commit 61725ba into PerryTS:main Aug 1, 2026
7 checks passed
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