unjs / unjs/jup

Shims run the runtime as a child process: pid mismatch, SIGKILL orphans the real runtime

Open
#8 0 comments 0 reactions 0 assignees View on GitHub

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 (and SIGSTOP/SIGCONT) cannot reach the runtime. The shim forwards SIGTERM/SIGHUP/SIGQUIT/SIGUSR1/SIGUSR2, but SIGKILL is 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, ps output, /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 by jup 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 stdio fds) 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.