`detached: true` in `spawnCommand` causes orphaned child processes when parent is force-killed by turbo
Summary
When portless is invoked as a child of turborepo (e.g. portless frontend next dev inside a turbo dev pipeline), stopping the dev server with Ctrl+C leaves the spawned command (e.g. next dev) running as an orphaned process.
Because the orphaned next dev process never exits cleanly, it doesn't remove its .next/dev/lock file. On subsequent runs, Next.js detects the stale lock and refuses to start:
⨯ Another next dev server is already running.
- Local: http://localhost:4062
- PID: 98670
- Dir: /Users/neef.rehman/repo/apps/frontend
- Log: .next/dev/logs/next-development.log
Run kill 98670 to stop it.The only workaround is to manually delete the lock file at apps/frontend/.next/dev/lock before restarting.
Root cause
spawnCommand (in cli.js) spawns the child process with detached: true on non-Windows platforms:
spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
stdio: "inherit",
env,
detached: true
});This places the child in its own process group. Portless registers SIGINT/SIGTERM handlers that call killTree(child, signal) (which does process.kill(-child.pid, signal)) and then immediately exits via process.exit(128 + ...).
This works fine when portless is the foreground process receiving the terminal signal directly. But when portless itself is a child managed by a task runner like turbo:
- Since turbo 2.9.7 (vercel/turborepo#12607), turbo intercepts SIGINT and manages shutdown itself, sending signals to its child process groups and applying a grace timer.
- Turbo sends SIGTERM to portless's process group. Portless's handler fires and forwards the signal to the child's separate group.
- If the child hasn't exited within turbo's grace window (~2s), turbo force-kills portless's process group — but because the child is in a different group (due to
detached: true), it's not reached. The child becomes orphaned. - The orphaned
next devprocess holds the.next/dev/lockfile indefinitely, blocking future dev server starts.
Before turbo 2.9.7, the terminal's SIGINT was delivered to the entire foreground process group by the kernel, so everything shut down (messily but completely). The new graceful-shutdown behavior in turbo exposes this interaction.
Suggested fix
Remove detached: true from spawnCommand. When the child shares a process group with portless, any signal sent to portless's group (whether from turbo, the terminal, or elsewhere) will also reach the child directly, making orphans impossible.
If detached: true was intentional for a specific reason (e.g. preventing the child from receiving stray signals from the terminal before portless can intercept them), an alternative would be to:
- Wait for the child to exit before calling
process.exit()in the signal handler, giving the child time to complete async cleanup. - Or set
detached: falseand rely on the shared group semantics.
Reproduction
- Set up a turbo monorepo with a
devtask that runsportless <name> next dev - Use turbo >= 2.9.7
- Run
turbo dev, wait for Next.js to start - Press Ctrl+C
- Observe that the
next devprocess (or its/bin/shwrapper) remains running (ps aux | grep next) - Run
turbo devagain — Next.js refuses to start due to the stale.next/dev/lockfile
Environment
- portless 0.13.1
- turbo 2.9.7+ (tested with latest)
- macOS (non-Windows path with
detached: true) - Node.js 24
- Next.js 15
Related
- vercel/turborepo#12652 — similar report of orphaned processes after turbo's graceful shutdown change
- vercel/turborepo#12607 — the PR that introduced graceful shutdown in turbo
Source: vercel-labs/portless