Repository navigation
fix(server): spawns no longer stall a large server on Linux - #101
Merged
Merged
Conversation
On Linux, Node spawns children with fork(), which copies the parent's page tables on the main thread. The cost grows with the server's memory (about 21 ms per GB, measured), so a long-running server that had grown to 6.8 GB spent 64-223 ms blocked per spawn. With ~2 git spawns a second, that was 22% of wall-clock time and froze HTTP and WebSocket handling for 2-11 s, which is why uploads and requests stalled. Children now start in a small helper process launched at boot. The server sends spawn requests over IPC and the helper relays each child's output back, so spawn cost no longer depends on the server's size. A small patch to @effect/platform-node-shared exposes the spawn call so the existing ChildProcessSpawner is reused unchanged. The helper's heap is capped so it stays small, a dead helper ends its in-flight children instead of hanging callers, and a helper that cannot launch falls back to spawning directly. macOS (posix_spawn) and Windows are unchanged. Claude-Session: https://claude.ai/code/session_01StkPGjC6GMoMkcwbiPzuUv
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
alexcstark
added a commit
that referenced
this pull request
Nov 4, 2026
On Linux, Node spawns children with fork(), which copies the parent's page tables on the main thread. The cost grows with the server's memory (about 21 ms per GB, measured), so a long-running server that had grown to 6.8 GB spent 64-223 ms blocked per spawn. With ~2 git spawns a second, that was 22% of wall-clock time and froze HTTP and WebSocket handling for 2-11 s, which is why uploads and requests stalled. Children now start in a small helper process launched at boot. The server sends spawn requests over IPC and the helper relays each child's output back, so spawn cost no longer depends on the server's size. A small patch to @effect/platform-node-shared exposes the spawn call so the existing ChildProcessSpawner is reused unchanged. The helper's heap is capped so it stays small, a dead helper ends its in-flight children instead of hanging callers, and a helper that cannot launch falls back to spawning directly. macOS (posix_spawn) and Windows are unchanged. Claude-Session: https://claude.ai/code/session_01StkPGjC6GMoMkcwbiPzuUv
alexcstark
added a commit
that referenced
this pull request
Nov 4, 2026
On Linux, Node spawns children with fork(), which copies the parent's page tables on the main thread. The cost grows with the server's memory (about 21 ms per GB, measured), so a long-running server that had grown to 6.8 GB spent 64-223 ms blocked per spawn. With ~2 git spawns a second, that was 22% of wall-clock time and froze HTTP and WebSocket handling for 2-11 s, which is why uploads and requests stalled. Children now start in a small helper process launched at boot. The server sends spawn requests over IPC and the helper relays each child's output back, so spawn cost no longer depends on the server's size. A small patch to @effect/platform-node-shared exposes the spawn call so the existing ChildProcessSpawner is reused unchanged. The helper's heap is capped so it stays small, a dead helper ends its in-flight children instead of hanging callers, and a helper that cannot launch falls back to spawning directly. macOS (posix_spawn) and Windows are unchanged. Claude-Session: https://claude.ai/code/session_01StkPGjC6GMoMkcwbiPzuUv
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.
On Linux, Node starts children with
fork()(libuv 1.52 still does;posix_spawnis Apple-only), which copies the parent's page tables on the main thread. The cost grows with the server's memory. A server that had run 6 days and grown to 6.8 GB blocked the main thread 64–223 ms per spawn. With about 2gitspawns a second, that was 22% of wall-clock time, and HTTP/WebSocket handling froze for 2–11 s: image uploads sat at 0%, and requests took up to 11.5 s.Evidence (lax6e, 2026-11-04)
perf: 73% of samples were the main thread in the kernel, mainlycopy_present_pte(30%) andzap_pte_range(28%), reached throughuv_spawn → _Fork → clone → dup_mmap.bpftrace: 222 forks in 120 s (218git), each blocking 64–223 ms, 26.7 s in total.Fix
t3 __spawn-helper, a fast path inbin.tsthat loads only Node built-ins), launched once at boot while the server is small.@effect/platform-node-sharedgets a small patch that exposes the spawn call (layerWithSpawn), so Effect'sChildProcessSpawnerand everything on it (ProcessRunner,git, provider CLIs) is reused unchanged.server.tsswaps it in, on Linux only.env/cwd, not the helper's startup snapshot.NODE_OPTIONS), because V8 collects lazily and an uncapped helper drifts upward.Proof
SpawnHelper.test.ts(6,it.live, through the realbin.tsfast path):processRunner.test.ts19/19.tsc-rspasses forapps/server;vp fmt --checkandvp lintare clean on the changed files. The lockfile is checked withpnpm install --frozen-lockfile.Not covered: spawns that bypass
ChildProcessSpawnerstill fork the server. That's the Claude SDK's CLI launch, node-pty for opening terminals, and some CLI-only commands, together about 4 of 222 measured spawns. macOS and Windows behave as before. Related: #99 (Cursor agent-store memory growth) and #100 (shared in-flight git reads).🤖 Generated with Claude Code
https://claude.ai/code/session_01StkPGjC6GMoMkcwbiPzuUv