---
title: "Dynoxide 0.9.8: fixing the orphan problem - Martin Hicks"
description: "Dynoxide's npm wrapper was leaving orphaned processes when backgrounded in npm scripts. Three fixes in 0.9.8 sort it out - SIGTERM handling in Rust, async spawn with signal forwarding, and PPID polling for the backgrounded case."
canonical: https://martinhicks.dev/articles/dynoxide-098-npm-process-lifecycle
last-updated: 2026-07-01
---

![The Dynoxide hexagon logo alongside the npm logo on a dark background](https://martinhicks.dev/images/articles/dynoxide-098-npm.png)6th April 2026

# Dynoxide 0.9.8: fixing the orphan problem

[Dynoxide 0.9.8](https://github.com/nubo-db/dynoxide) 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:

```rust
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.

[Changelog](https://github.com/nubo-db/dynoxide/blob/main/CHANGELOG.md) and [issue](https://github.com/nubo-db/dynoxide/issues/2) on GitHub.

---

Source: https://martinhicks.dev/articles/dynoxide-098-npm-process-lifecycle
