Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7157-macos-child-process-posix-spawn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
**macOS `child_process` fork/dyld deadlock:** `exec`/`spawn`/`execFile` (and their `Sync` forms) could deadlock a child on macOS. `std::process::Command` falls back from `posix_spawn` to `fork`+`exec` whenever a bare command name is combined with an `env` option (`env_clear()` sets `env_saw_path()`), and `fork` from Perry's multithreaded runtime (async reactor + GC/worker threads) leaves the child holding locks/Mach state from parent threads that no longer exist — so a fast child like `sh -c "echo hi"` hangs post-`exec` in dyld (`RemoteNotificationResponder::blockOnSynchronousEvent`), the reader/waiter threads block in `read()`/`wait4()`, and the main loop idles in `js_wait_for_event`. Perry now resolves a bare command name to its absolute path in the child's effective PATH before building the `Command`, keeping std on the `posix_spawn` fast path (`argv[0]` preserved via `arg0`); the `exec` shell uses the absolute `/bin/sh`. Verified with a dyld interposer: `env`-carrying `exec`/`spawnSync` go from `fork()` to `posix_spawn` with output/exit-code/argv capture unchanged. `detached` (setsid), `fork()`'s IPC `dup2`, and uid/gid necessarily keep std's fork path (not expressible through std's `posix_spawn`) and are documented inline. Linux behavior is unchanged.
23 changes: 17 additions & 6 deletions crates/perry-runtime/src/child_process/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ pub extern "C" fn js_child_process_exec_sync(
// Execute the command using the shell, honoring `cwd`/`env` options.
#[cfg(unix)]
let mut command = {
let mut c = Command::new("sh");
// 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");
c.arg("-c").arg(&cmd_str);
c
};
Expand Down Expand Up @@ -277,7 +280,10 @@ pub extern "C" fn js_child_process_exec(cmd_ptr: *const StringHeader, arg1: f64,
// `env` from the options are applied here.
#[cfg(unix)]
let mut command = {
let mut c = Command::new("sh");
// 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");
c.arg("-c").arg(&cmd_str);
c
};
Expand Down Expand Up @@ -354,7 +360,7 @@ pub extern "C" fn js_child_process_exec_file(

// `cwd`/`env` come from the options slot; when `opts_val` is the callback
// (`execFile(file, args, cb)`) it's a closure, so the helper no-ops.
let mut command = Command::new(&file_str);
let mut command = cp_command_for_program(&file_str, opts_val);
command.args(&arg_strs);
cp_apply_options(&mut command, opts_val);
let run_options = cp_read_async_run_options(opts_val);
Expand Down Expand Up @@ -393,7 +399,7 @@ pub extern "C" fn js_child_process_exec_file_sync(
return cp_box_output(b"", &mode);
}
let arg_strs = cp_args_from_value(args_val);
let mut command = Command::new(&file_str);
let mut command = cp_command_for_program(&file_str, opts_val);
command.args(&arg_strs);
cp_apply_argv0(&mut command, opts_val);
cp_apply_options(&mut command, opts_val);
Expand Down Expand Up @@ -480,7 +486,10 @@ extern "C" fn cp_promisified_exec(_closure: *const ClosureHeader, cmd_val: f64,
let cmd = cp_value_to_string(cmd_val).unwrap_or_default();
#[cfg(unix)]
let mut command = {
let mut c = Command::new("sh");
// 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");
c.arg("-c").arg(&cmd);
c
};
Expand All @@ -501,7 +510,9 @@ extern "C" fn cp_promisified_exec_file(
) -> f64 {
let file = cp_value_to_string(file_val).unwrap_or_default();
let arg_strs = cp_args_from_value(args_val);
let mut command = Command::new(&file);
// The 2-arg promisify(execFile) wrapper has no options slot; resolve a bare
// program against the parent PATH to keep std on `posix_spawn`.
let mut command = cp_command_for_program(&file, cp_undefined());
command.args(&arg_strs);
// The 2-arg promisify(execFile) wrapper has no options slot.
cp_promisified_run(
Expand Down
7 changes: 5 additions & 2 deletions crates/perry-runtime/src/child_process/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,13 @@ pub(crate) use builder::{
};

// options.rs — command option application (cwd/env/uid/gid/argv0/detached/stdio).
#[cfg(unix)]
pub(crate) use options::cp_resolve_program_path;
pub(crate) use options::{
cp_abort_signal_is_aborted, cp_apply_argv0, cp_apply_detached, cp_apply_live_stdio,
cp_apply_options, cp_build_command, cp_read_abort_signal, cp_read_stdio, cp_spawnargs_argv0,
cp_stdio_from_fd, cp_stdio_js_value, cp_stdio_stream_fd, CpStdio,
cp_apply_options, cp_build_command, cp_command_for_program, cp_read_abort_signal,
cp_read_stdio, cp_spawnargs_argv0, cp_stdio_from_fd, cp_stdio_js_value, cp_stdio_stream_fd,
CpStdio,
};

// output.rs — output encoding, error shape, exit decoding.
Expand Down
169 changes: 167 additions & 2 deletions crates/perry-runtime/src/child_process/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,12 @@ pub(crate) fn cp_apply_detached(command: &mut Command, opts_val: f64) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// NOTE: a `pre_exec` closure forces std onto the `fork`+`exec` path
// (posix_spawn cannot run arbitrary code), so `detached` children do not
// benefit from the posix_spawn fork/dyld-deadlock fix. `setsid` is not
// expressible through std's posix_spawn wrapper (no `POSIX_SPAWN_SETSID`
// knob), and `detached` is a rare, deliberate full-session-detach —
// unlike the common `exec`/`spawn` paths, it is not converted here.
unsafe {
command.pre_exec(|| {
if libc::setsid() < 0 {
Expand Down Expand Up @@ -370,6 +376,98 @@ fn cp_default_shell() -> String {
}
}

/// Fallback search path when a child's environment carries no `PATH` — the same
/// default `execvp(3)` uses (`_PATH_DEFPATH`).
#[cfg(unix)]
const CP_DEFAULT_PATH: &str = "/usr/bin:/bin:/usr/sbin:/sbin";

/// The child's effective `PATH` for resolving a bare command name. When an `env`
/// option is present the child's environment *replaces* the parent's (Node
/// semantics), so its `PATH` — not the parent's — governs the lookup; a missing
/// `PATH` falls back to the `execvp` default. With no `env` option the parent's
/// `PATH` applies.
#[cfg(unix)]
fn cp_effective_path(opts_val: f64) -> String {
if cp_object_ptr(opts_val).is_some() {
let env_val = cp_get_field(opts_val, b"env");
if cp_object_ptr(env_val).is_some() {
if let Some(p) = cp_value_to_string(cp_get_field(env_val, b"PATH")) {
if !p.is_empty() {
return p;
}
}
return CP_DEFAULT_PATH.to_string();
}
}
std::env::var("PATH").unwrap_or_else(|_| CP_DEFAULT_PATH.to_string())
}

/// Whether `path` names an executable regular file (following symlinks).
#[cfg(unix)]
fn cp_is_executable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && (m.permissions().mode() & 0o111 != 0))
.unwrap_or(false)
}

/// Resolve a bare command name to an absolute path by walking `path` (a
/// colon-separated `PATH`), returning the first executable match. An empty
/// `PATH` element means the current directory (POSIX `execvp` semantics).
#[cfg(unix)]
pub(crate) fn cp_resolve_program_path(program: &str, path: &str) -> Option<String> {
for dir in path.split(':') {
let base = if dir.is_empty() { "." } else { dir };
let candidate = std::path::Path::new(base).join(program);
if cp_is_executable(&candidate) {
return candidate.into_os_string().into_string().ok();
}
}
None
}

/// Build a `Command` for `program`, resolving a bare command name to its
/// absolute path in the child's effective `PATH`.
///
/// This is the macOS fork/dyld deadlock fix. `std::process::Command` uses
/// `posix_spawn` only 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 triggers `env_clear()`) drops it onto the `fork`+`exec`
/// fallback (see `library/std/src/sys/pal/unix/process/process_unix.rs`,
/// `env_saw_path() && !program_is_path()`). On macOS a `fork` from Perry's
/// multithreaded runtime (async reactor + GC/worker threads) can deadlock the
/// child post-`exec` in dyld (`RemoteNotificationResponder::
/// blockOnSynchronousEvent`) when the process is being observed by a Mach
/// notification port (telemetry, a crash reporter, a debugger): the child
/// inherits locks/Mach state from parent threads that don't exist after
/// `fork`. Resolving the name to an absolute path here keeps std on the
/// `posix_spawn` fast path.
///
/// The original name is preserved as `argv[0]` (`arg0`) so the child sees the
/// same `process.argv[0]` it would have gotten from the bare name. When nothing
/// resolves we fall back to the bare name unchanged — a genuine `ENOENT` never
/// `exec`s a real image, so it cannot hit the dyld hang, and the error surface
/// stays identical.
pub(crate) fn cp_command_for_program(program: &str, opts_val: f64) -> Command {
#[cfg(unix)]
{
if !program.is_empty() && !program.contains('/') {
let path = cp_effective_path(opts_val);
if let Some(abs) = cp_resolve_program_path(program, &path) {
use std::os::unix::process::CommandExt;
let mut command = Command::new(abs);
command.arg0(program);
return command;
}
}
}
#[cfg(not(unix))]
{
let _ = opts_val;
}
Command::new(program)
}

/// Whether a self-launch uses a Node CLI mode that evaluates source text.
fn cp_should_use_node_interpreter(cmd: &str, args: &[String]) -> bool {
let is_self = std::env::args().next().as_deref() == Some(cmd)
Expand Down Expand Up @@ -423,14 +521,16 @@ pub(crate) fn cp_build_command(cmd: &str, args: &[String], opts_val: f64) -> Com
line.push(' ');
line.push_str(a);
}
let mut c = Command::new(shell_bin);
// Resolve a bare shell name to an absolute path so std stays on
// `posix_spawn` (see `cp_command_for_program`).
let mut c = cp_command_for_program(&shell_bin, opts_val);
#[cfg(windows)]
c.arg("/d").arg("/s").arg("/c").arg(line);
#[cfg(not(windows))]
c.arg("-c").arg(line);
c
} else {
let mut c = Command::new(program);
let mut c = cp_command_for_program(&program, opts_val);
c.args(args);
c
};
Expand All @@ -441,6 +541,71 @@ pub(crate) fn cp_build_command(cmd: &str, args: &[String], opts_val: f64) -> Com
command
}

#[cfg(all(test, unix))]
mod posix_spawn_tests {
use super::{cp_command_for_program, cp_resolve_program_path};
use crate::child_process::cp_undefined;

/// A bare command name in a real `PATH` resolves to an executable absolute
/// path — the precondition std needs to pick `posix_spawn` over `fork`.
#[test]
fn resolves_bare_name_to_absolute_executable() {
let resolved = cp_resolve_program_path("sh", "/nonexistent:/bin:/usr/bin")
.expect("sh should resolve on a POSIX system");
assert!(
resolved.starts_with('/'),
"expected an absolute path, got {resolved}"
);
assert!(resolved.ends_with("/sh"));
assert!(std::path::Path::new(&resolved).exists());
}

/// A name that cannot be found returns `None` (caller falls back to the bare
/// name, which then fails ENOENT before exec'ing any real image).
#[test]
fn missing_program_does_not_resolve() {
assert!(cp_resolve_program_path("perry-definitely-missing-xyz", "/bin:/usr/bin").is_none());
}

/// `cp_command_for_program` rewrites a bare name to an absolute path so std
/// stays on `posix_spawn`; an already-absolute program is passed through
/// unchanged.
#[test]
fn command_program_is_absolute_for_bare_name() {
let cmd = cp_command_for_program("sh", cp_undefined());
let program = cmd.get_program().to_string_lossy().into_owned();
assert!(
program.starts_with('/') && program.ends_with("/sh"),
"bare name should resolve to an absolute path; got {program}"
);

let passthrough = cp_command_for_program("/bin/sh", cp_undefined());
assert_eq!(passthrough.get_program().to_string_lossy(), "/bin/sh");
}

/// End-to-end: the resolved (absolute-path, no-`pre_exec`) command spawns via
/// std's `posix_spawn` path and captures output correctly. This is exactly
/// the shape that deadlocked in dyld when std took the `fork`+`exec` fallback
/// on macOS. (Full GC-stress N/N verification uses the compiled repro under
/// `PERRY_GC_FORCE_EVACUATE=1`; a raw unit test cannot drive Perry's
/// thread-local GC without runtime init.)
#[test]
fn resolved_command_runs_and_captures_output() {
let mut cmd = cp_command_for_program("sh", cp_undefined());
assert!(
cmd.get_program().to_string_lossy().starts_with('/'),
"resolved program must be an absolute path to keep std on posix_spawn"
);
let out = cmd
.arg("-c")
.arg("printf ok-%s 42")
.output()
.expect("spawn resolved sh");
assert!(out.status.success());
assert_eq!(out.stdout, b"ok-42");
}
}

#[cfg(test)]
mod tests {
use super::cp_should_use_node_interpreter;
Expand Down
67 changes: 54 additions & 13 deletions crates/perry-runtime/src/child_process/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,55 @@ pub extern "C" fn js_child_process_spawn_background(
None => return std::ptr::null_mut(),
};

let mut command = Command::new(&cmd_str);
// 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)
}
};
Comment on lines +88 to +136

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.


// Add arguments if provided
if args_ptr != 0 {
Expand All @@ -102,18 +150,11 @@ pub extern "C" fn js_child_process_spawn_background(
}
}

// Parse env JSON if provided (not null/undefined)
let env_bits = env_json_val.to_bits();
if env_bits != TAG_NULL_BITS && env_bits != TAG_UNDEFINED_BITS {
if let Some(env_json) = extract_string_from_nanboxed(env_json_val) {
if let Ok(map) =
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&env_json)
{
for (k, v) in map {
if let Some(val_str) = v.as_str() {
command.env(k, val_str);
}
}
// Apply the parsed env (string values only), matching prior behavior.
if let Some(map) = env_map {
for (k, v) in map {
if let Some(val_str) = v.as_str() {
command.env(k, val_str);
}
}
}
Expand Down
Loading