Skip to content

macOS: use posix_spawn instead of fork+exec for child processes and patch HOME so its not null and doesn't throws a segfault in llama.cpp - #1708

Merged
Geramy merged 5 commits into
mainfrom
geramy/macos-posix-spawn
Apr 22, 2026
Merged

macOS: use posix_spawn instead of fork+exec for child processes and patch HOME so its not null and doesn't throws a segfault in llama.cpp #1708
Geramy merged 5 commits into
mainfrom
geramy/macos-posix-spawn

Conversation

@Geramy

@Geramy Geramy commented Apr 22, 2026

Copy link
Copy Markdown
Member

Problem: lemond spawns llama-server via fork()+execvp(). On macOS, fork() leaves the child with corrupted Mach-port and XPC-bootstrap state that execvp() does not reset. llama.cpp b8884+ now runs a ggml-metal probe at startup that calls [MTLDevice newLibraryWithSource:] — which routes through MTLCompilerService XPC — and dies on the broken channel before the model is opened. Direct terminal runs work; only lemond-spawned children fail (~130ms, exit code -1).

Fix: on APPLE, replace fork()+execvp() with posix_spawn. Preserves pipe/working-dir semantics. Adds POSIX_SPAWN_CLOEXEC_DEFAULT to avoid leaking lemond FDs into the child, and POSIX_SPAWN_SETSIGDEF to reset inherited SIG_IGN dispositions. Linux and Windows paths unchanged.

Problem: lemond spawns llama-server via fork()+execvp(). On macOS, fork()
leaves the child with corrupted Mach-port and XPC-bootstrap state that
execvp() does not reset. llama.cpp b8884+ now runs a ggml-metal probe at
startup that calls [MTLDevice newLibraryWithSource:] — which routes
through MTLCompilerService XPC — and dies on the broken channel before
the model is opened. Direct terminal runs work; only lemond-spawned
children fail (~130ms, exit code -1).

Fix: on __APPLE__, replace fork()+execvp() with posix_spawn. Preserves
pipe/working-dir semantics. Adds POSIX_SPAWN_CLOEXEC_DEFAULT to avoid
leaking lemond FDs into the child, and POSIX_SPAWN_SETSIGDEF to reset
inherited SIG_IGN dispositions. Linux and Windows paths unchanged.
@Geramy

Geramy commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

Back story not needed really but very informational and nice to have.

Why fork() is unsafe under Apple frameworks
This is a long-documented Apple platform rule. The most authoritative source is Apple's own open-source Objective-C runtime (objc4). From runtime/objc-initialize.mm (apple-oss-distributions/objc4), in the comment block titled "Fork Safety or: We Tried So Hard":

"It is impossible to make +initialize fork-safe in the general case. The standard approach of acquiring all locks pre-fork doesn't work, because another thread might be in +initialize waiting on the thread calling fork, and trying to wait for that to complete would result in a deadlock."

The child-side function performForkChildInitialize (same file) prints the familiar runtime error:

"+[%s initialize] may have been in progress in another thread when fork() was called."

and calls objc_initializeAfterForkError(cls) before crashing. The kill switch OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES is the existence proof — Apple explicitly shipped a per-process override because they know fork-then-exec leaves children in an unrepresentable runtime state. It doesn't fix the underlying corruption, it just silences the abort.

The same unsafety extends to CoreFoundation (Mach ports and XPC bootstrap handles don't reset across fork), Grand Central Dispatch (pthread workqueue state), and the higher-level frameworks that sit on top of them — including Metal. exec() replaces the memory image but does not reset Mach ports, XPC connections, dispatch queues registered with the kernel, or pthread workqueue bookkeeping.

Apple's documented alternative is posix_spawn(2). From Apple's local man page (man posix_spawn on any macOS system):

"The posix_spawn() function creates a new process from the executable file, called the new process file, specified by path… File descriptors open in the calling process image remain open in the new process image, except for those for which the close-on-exec flag is set…"

The important distinction is that posix_spawn creates the child without the "inherit all parent state then selectively discard it" semantics of fork+exec. Under the hood on Darwin it's a single kernel transaction that builds a fresh process context, so Mach-level state doesn't leak in.

Geramy added 2 commits April 22, 2026 11:40
- backend_versions.json: metal b8460 -> b8884 (latest llama.cpp release,
  paired with the posix_spawn spawn-path fix so macOS can actually
  tolerate b8884's ggml-metal probe).
- server_env_vars.py: drop the five @unittest.skipIf(IS_MACOS, ...)
  decorators on test_llamacpp_backend / _args, test_whispercpp_backend /
  _args, and test_flm_args so the env-var snapshot checks run on macOS
  too. The setUpClass already guards the matching env vars with
  `if not IS_MACOS` — those tests will now report real failures if the
  env path diverges on macOS instead of silently skipping.
llama.cpp b8884+ libllama-common calls getenv("HOME") in
fs_get_cache_directory during CLI arg parsing and feeds the result
straight into std::string without a NULL check, so llama-server segfaults
before the model loads whenever HOME is unset (EXC_BAD_ACCESS /
SIGSEGV at 0x0 in _platform_strlen via std::string::insert, caller
hf_cache::migrate_old_cache_to_hf_cache).

LaunchDaemons installed under /Library/LaunchDaemons/ only inherit the
EnvironmentVariables declared in their plist; the lemond plist sets
HF_HOME and PATH but not HOME, so every child spawned by lemond hit the
crash. Terminal/sudo spawns preserve HOME and were unaffected, which is
why the bug only surfaced under the installed daemon.

Fix: when spawning llama-server on __APPLE__, check if HOME is set in
the parent env; if not, fall back to getpwuid(getuid())->pw_dir (or
/var/root) and pass it through env_vars. No plist change, no launchd
reconfiguration — just a guaranteed HOME in the child.
@Geramy

Geramy commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

setting HOME here to help mitigate llama.cpp but for macOS, but realistically it should be fixed in llama.cpp -> PR ggml-org/llama.cpp#22263

@Geramy Geramy changed the title macOS: use posix_spawn instead of fork+exec for child processes macOS: use posix_spawn instead of fork+exec for child processes and patch HOME so its not null and doesn't throws a segfault in llama.cpp Apr 22, 2026
@Geramy
Geramy requested review from jeremyfowers and superm1 April 22, 2026 19:34
Comment thread test/server_env_vars.py
Comment thread src/cpp/server/backends/llamacpp_server.cpp
- llamacpp_server.cpp: annotate the HOME-fallback block with a link to
  ggml-org/llama.cpp#22263 ("common: fix macOS cache path segfault when
  HOME is unset"). Remove this workaround once that PR merges and the
  metal version pinned in backend_versions.json includes it.
- server_env_vars.py: restore the IS_MACOS skip on test_flm_args. FLM
  is an NPU-only backend and is genuinely unavailable on macOS, so this
  skip is platform truth, not a CI workaround — it was removed in
  34eb4db alongside the CI-only skips, but it should have stayed.
@Geramy
Geramy requested a review from superm1 April 22, 2026 19:40
Comment thread test/server_env_vars.py
Revert the removal of @unittest.skipIf(IS_MACOS, ...) on
test_llamacpp_backend / _args and test_whispercpp_backend / _args.
These tests rely on env vars that setUpClass only sets when not IS_MACOS,
so running them on macOS produces noise, not coverage. Put them back the
way they were before this branch touched the file.
@Geramy
Geramy requested a review from jeremyfowers April 22, 2026 20:54
@Geramy
Geramy enabled auto-merge April 22, 2026 21:07
@jeremyfowers

Copy link
Copy Markdown
Member

@claude review

@github-actions

github-actions Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Claude finished @jeremyfowers's task in 2m 0s —— View job


PR Review

  • Gather context
  • Read changed files
  • Analyze implementation
  • Post review feedback

The motivation is solid and the posix_spawn approach is the right call for macOS. A few issues below, one of which is a real behavioral regression.


Bug: stdout/stderr lost when inherit_output && !filter_health_logs

src/cpp/server/utils/process_manager.cpp ~line 453

The file-actions block has three branches:

if (inherit_output && filter_health_logs) {
    // pipes → dup2 into stdout/stderr   ✓
} else if (!inherit_output) {
    // /dev/null redirect                ✓
}
// inherit_output && !filter_health_logs → nothing added  ← bug

Because POSIX_SPAWN_CLOEXEC_DEFAULT closes every parent fd in the child (including fd 1 and fd 2) unless they are explicitly mentioned in a file_actions call, when this path is taken the child starts with no stdout and no stderr. The fork+execvp path inherits them naturally, so this is a silent regression — e.g., in debug mode when filter_health_logs is false, no backend output will appear.

Fix: add an explicit else branch that dup2s each fd onto itself (the Apple-documented trick to "opt in" an fd when POSIX_SPAWN_CLOEXEC_DEFAULT is active):

} else {
    // inherit_output && !filter_health_logs
    // POSIX_SPAWN_CLOEXEC_DEFAULT would otherwise close these in the child
    posix_spawn_file_actions_adddup2(&file_actions, STDOUT_FILENO, STDOUT_FILENO);
    posix_spawn_file_actions_adddup2(&file_actions, STDERR_FILENO, STDERR_FILENO);
}

Stdin (fd 0) has the same issue for completeness, but backend processes don't read stdin so it's low priority. Fix this →


Minor: getpwuid is not thread-safe

src/cpp/server/backends/llamacpp_server.cpp ~line 449

struct passwd* pw = getpwuid(getuid());

getpwuid uses a static internal buffer and is not async-signal-safe or thread-safe. The Router serves concurrent HTTP requests and load() can be called from different threads. Use getpwuid_r instead:

struct passwd pw_buf{};
struct passwd* pw = nullptr;
char pw_str_buf[4096];
getpwuid_r(getuid(), &pw_buf, pw_str_buf, sizeof(pw_str_buf), &pw);
std::string fallback_home = (pw && pw->pw_dir) ? pw->pw_dir : "/var/root";

Fix this →


Nit: stdin not inherited in pipe case

When inherit_output && filter_health_logs, the pipe setup closes stdin (fd 0) in the child via POSIX_SPAWN_CLOEXEC_DEFAULT — it's never mentioned in file_actions. The fork path inherits stdin. This is almost certainly harmless for HTTP server backends, but it's a behavioral difference worth being aware of.


Everything else looks good

  • The core posix_spawn rationale is correct and well-explained.
  • POSIX_SPAWN_CLOEXEC_DEFAULT | POSIX_SPAWN_SETSIGDEF is the right combination of flags.
  • The env-var merging logic (strip overrides from environ, then append env_vars) is correct.
  • The addchdir_np with #pragma clang diagnostic suppression is the right approach while older SDKs remain in scope.
  • The HOME fallback via getpwuid is correct in logic (just needs the thread-safe variant).
  • The metal version bump to b8884 is the expected prerequisite for this fix.
  • Test skips in server_env_vars.py are correct.

The stdout/stderr regression should be fixed before merge. The getpwuid_r change is a clean correctness fix worth doing at the same time.

@jeremyfowers jeremyfowers left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we're back, baby!

@Geramy
Geramy added this pull request to the merge queue Apr 22, 2026
Merged via the queue into main with commit 94559bc Apr 22, 2026
73 checks passed
@Geramy
Geramy deleted the geramy/macos-posix-spawn branch April 22, 2026 21:53
meghsat pushed a commit that referenced this pull request Jul 10, 2026
…atch HOME so its not null and doesn't throws a segfault in llama.cpp (#1708)

* macOS: use posix_spawn instead of fork+exec for child processes

Problem: lemond spawns llama-server via fork()+execvp(). On macOS, fork()
leaves the child with corrupted Mach-port and XPC-bootstrap state that
execvp() does not reset. llama.cpp b8884+ now runs a ggml-metal probe at
startup that calls [MTLDevice newLibraryWithSource:] — which routes
through MTLCompilerService XPC — and dies on the broken channel before
the model is opened. Direct terminal runs work; only lemond-spawned
children fail (~130ms, exit code -1).

Fix: on __APPLE__, replace fork()+execvp() with posix_spawn. Preserves
pipe/working-dir semantics. Adds POSIX_SPAWN_CLOEXEC_DEFAULT to avoid
leaking lemond FDs into the child, and POSIX_SPAWN_SETSIGDEF to reset
inherited SIG_IGN dispositions. Linux and Windows paths unchanged.

* bump llamacpp metal to b8884; remove macOS skipIfs on env-var tests

- backend_versions.json: metal b8460 -> b8884 (latest llama.cpp release,
  paired with the posix_spawn spawn-path fix so macOS can actually
  tolerate b8884's ggml-metal probe).
- server_env_vars.py: drop the five @unittest.skipIf(IS_MACOS, ...)
  decorators on test_llamacpp_backend / _args, test_whispercpp_backend /
  _args, and test_flm_args so the env-var snapshot checks run on macOS
  too. The setUpClass already guards the matching env vars with
  `if not IS_MACOS` — those tests will now report real failures if the
  env path diverges on macOS instead of silently skipping.

* macOS: set HOME in llama-server child env when unset

llama.cpp b8884+ libllama-common calls getenv("HOME") in
fs_get_cache_directory during CLI arg parsing and feeds the result
straight into std::string without a NULL check, so llama-server segfaults
before the model loads whenever HOME is unset (EXC_BAD_ACCESS /
SIGSEGV at 0x0 in _platform_strlen via std::string::insert, caller
hf_cache::migrate_old_cache_to_hf_cache).

LaunchDaemons installed under /Library/LaunchDaemons/ only inherit the
EnvironmentVariables declared in their plist; the lemond plist sets
HF_HOME and PATH but not HOME, so every child spawned by lemond hit the
crash. Terminal/sudo spawns preserve HOME and were unaffected, which is
why the bug only surfaced under the installed daemon.

Fix: when spawning llama-server on __APPLE__, check if HOME is set in
the parent env; if not, fall back to getpwuid(getuid())->pw_dir (or
/var/root) and pass it through env_vars. No plist change, no launchd
reconfiguration — just a guaranteed HOME in the child.

* note upstream fix for HOME crash; restore FLM macOS skip

- llamacpp_server.cpp: annotate the HOME-fallback block with a link to
  ggml-org/llama.cpp#22263 ("common: fix macOS cache path segfault when
  HOME is unset"). Remove this workaround once that PR merges and the
  metal version pinned in backend_versions.json includes it.
- server_env_vars.py: restore the IS_MACOS skip on test_flm_args. FLM
  is an NPU-only backend and is genuinely unavailable on macOS, so this
  skip is platform truth, not a CI workaround — it was removed in
  34eb4db alongside the CI-only skips, but it should have stayed.

* restore IS_MACOS skipIfs in server_env_vars tests

Revert the removal of @unittest.skipIf(IS_MACOS, ...) on
test_llamacpp_backend / _args and test_whispercpp_backend / _args.
These tests rely on env vars that setUpClass only sets when not IS_MACOS,
so running them on macOS produces noise, not coverage. Put them back the
way they were before this branch touched the file.
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.

3 participants