Dynoxide 0.9.8: fixing the orphan problem
Dynoxide 0.9.8 fixes a bug where backgrounding dynoxide in an npm script left it running after the parent process exited. The port stayed bound, and the next npm run dev failed with a port conflict. There were three separate causes.
The Rust server ignored SIGTERM
kill <pid> sends SIGTERM by default, but Dynoxide only handled SIGINT (Ctrl+C). The fix is a tokio::select! between ctrl_c() and a SIGTERM listener:
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut sigterm =
signal(SignalKind::terminate()).expect("failed to install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {},
_ = sigterm.recv() => {},
}
}
#[cfg(not(unix))]
{
tokio::signal::ctrl_c()
.await
.expect("failed to install CTRL+C handler");
}
eprintln!("\nShutting down...");
}
This applies to all installs (Homebrew, cargo, GitHub Action), not just npm.
spawnSync blocks the event loop
The npm wrapper used spawnSync to launch the Rust binary. That blocks the Node.js event loop entirely - no signal handlers fire, no process.on('exit'), nothing. The shim can't forward signals to the child because it's stuck in a synchronous call.
Switched to async spawn with explicit signal forwarding (SIGINT, SIGTERM, SIGHUP), same pattern esbuild and Biome use. The child spawns with detached: true to avoid double SIGINT delivery - without it, Ctrl+C sends SIGINT to both the shim and the child via the foreground process group, and the shim forwards it again. Detaching makes explicit forwarding the only signal path.
There's also double-signal escalation: first Ctrl+C forwards SIGINT for graceful shutdown, second sends SIGKILL.
Backgrounded processes never get signals
The & in a typical npm script pattern like dynoxide & sleep 1 && npm run seed && react-router dev puts dynoxide outside the foreground process group. Ctrl+C goes to the dev server. SIGTERM from kill targets the parent shell. No signal reaches dynoxide at all.
The fix is polling process.ppid on a 1-second interval. When the parent dies, the OS reparents the process to PID 1 (or launchd on macOS) and the PPID changes. When detected, the shim sends SIGTERM to the child with a SIGKILL fallback after a grace period. The interval uses .unref() so short-lived commands like dynoxide --help exit immediately.
detached and PPID polling are coupled
detached: true means the OS won't automatically kill the child if the wrapper crashes or gets SIGKILL'd. Without PPID polling, a detached child orphans more easily than the old spawnSync behaviour. Removing the polling while keeping detached: true would make things worse, not better.
Upgrade
npm install --save-dev dynoxide@latest
Or if you're using Homebrew or cargo, the SIGTERM fix applies regardless of how you installed it.