Shims run the runtime as a child process: pid mismatch, SIGKILL orphans the real runtime
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 68
- Forks
- 0
- Avg merge
- 30m
- Merged PRs (30d)
- 1
Description
Summary
The generated shims (/opt/jup/bin/{node,bun,deno,...}) run the real runtime as a child process of a Node.js wrapper instead of replacing the process image. So the pid a caller gets back from spawn("bun", ...) is the shim's pid, not the runtime's. Anything that relies on the pid being the runtime breaks:
SIGKILL(andSIGSTOP/SIGCONT) cannot reach the runtime. The shim forwardsSIGTERM/SIGHUP/SIGQUIT/SIGUSR1/SIGUSR2, butSIGKILLis uncatchable, so killing the pid only kills the wrapper. The real runtime is reparented to PID 1 and keeps running — with its stdio, listening ports and any IPC channels to its own children still open.- Process-tree / pid-based tooling (
process.ppid,kill(pid, 0), pidfiles, supervisors,psoutput,/proc/<pid>/...) sees the wrapper. - Any state the wrapper fails to relay (extra fds, exit-by-signal timing, the IPC relay from #7) is lost at the extra hop.
This is a follow-up to #7: the IPC relay in 0.5.6 fixes process.send, but the underlying cause — an intermediate process — is still there and shows up as soon as the pid matters.
Environment
- jup
0.5.6(/opt/jup/home/self/0.5.6), shim generated byjup enable - shim interpreter: Node.js
24.21.0(/opt/jup/home/node/bin/node) - runtime: Bun
1.4.0(/opt/jup/home/v1/bun/1.4.0/bin/bun) - Linux (aarch64)
Reproduction
orphan-child.mjs:
console.log("runtime pid", process.pid, "ppid", process.ppid);
setInterval(() => console.log("runtime still alive, ppid", process.ppid), 1000);
orphan-parent.mjs:
import { spawn } from "node:child_process";
const [cmd, ...args] = process.argv.slice(2);
const child = spawn(cmd, args, { stdio: ["ignore", "inherit", "inherit", "ipc"] });
console.log("spawned pid", child.pid);
setTimeout(() => { console.log("SIGKILL", child.pid); child.kill("SIGKILL"); }, 1500);
child.on("exit", (code, sig) => console.log("spawned process exited", code, sig));
setTimeout(() => process.exit(0), 4000);
node orphan-parent.mjs "$(which bun)" orphan-child.mjs # shim
node orphan-parent.mjs /opt/jup/home/v1/bun/1.4.0/bin/bun orphan-child.mjs # real binary
Results
## via shim: /opt/jup/bin/bun
spawned pid 19275
runtime pid 19282 ppid 19275 <- runtime pid != spawned pid
runtime still alive, ppid 19275
SIGKILL 19275
spawned process exited null SIGKILL <- only the wrapper died
runtime still alive, ppid 1 <- runtime orphaned, keeps running
runtime still alive, ppid 1
## via real binary
spawned pid 19305
runtime pid 19305 ppid 19298 <- same pid
runtime still alive, ppid 19298
SIGKILL 19305
spawned process exited null SIGKILL <- runtime is gone
Where it bit us
unjs/env-runner has regression tests for orphaned workers (test/orphan.test.ts, unjs/env-runner#23): a supervisor process spawns a worker over IPC, the test SIGKILLs the supervisor and asserts the worker exits on disconnect. With a jup-shimmed bun, the test kills the wrapper, the real Bun supervisor survives as an orphan holding the worker's IPC channel open, and the worker (correctly) never sees disconnect — 2 tests fail locally and pass in CI (real binaries via oven-sh/setup-bun). The orphaned Bun processes then also keep vitest's fork pool from shutting down ([vitest-pool]: Timeout terminating forks worker). Same scenario applies to any dev server / test runner / process manager that kills a jup-managed runtime by pid.
Suggested fix
On POSIX, replace the process image instead of spawning a child: Node.js has process.execve(file, args, env) since v22.15 / v23.11 (the shim already runs on jup's own bundled Node 24, so it is available). With execve:
- the spawned pid is the runtime pid, so every signal (including
SIGKILL/SIGSTOP) and every pid-based check works natively; - fds 0–2 and any extra fds (the IPC channel fd, user
stdiofds) are inherited by the kernel — no relay needed, which would also make the #7 fix simpler; - no lingering Node process per invocation.
Verified locally that process.execve("/opt/jup/home/v1/bun/1.4.0/bin/bun", ["bun", ...args], env) from a shim keeps the pid and runs the real Bun. One caveat I hit while checking the IPC part: Node strips NODE_CHANNEL_FD from process.env when it sets up its own channel in the shim, so the shim has to put it back (NODE_CHANNEL_FD=3 + NODE_CHANNEL_SERIALIZATION_MODE) before execve. In my quick test Bun then did get process.send, but messages did not reach the Node parent — I didn't dig further (may be the shim's Node runtime having already initialised the channel / flipped the fd to non-blocking), so the IPC path via execve probably needs a closer look on your side. The current spawn handover could stay as the Windows fallback (execve is not available there).
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the generated shims invoked by jup enable and trace their Node.js child-process handover. Reproduce the mismatch with orphan-parent.mjs and orphan-child.mjs, then verify POSIX process.execve preserves the runtime PID, signals, file descriptors, and IPC while keeping the current spawn path for Windows; the orphan-worker scenario should no longer leave the runtime alive.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100