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 dev process holds the .next/dev/lock file 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: false and rely on the shared group semantics.
Reproduction
- Set up a turbo monorepo with a
dev task that runs portless <name> next dev
- Use turbo >= 2.9.7
- Run
turbo dev, wait for Next.js to start
- Press Ctrl+C
- Observe that the
next dev process (or its /bin/sh wrapper) remains running (ps aux | grep next)
- Run
turbo dev again — Next.js refuses to start due to the stale .next/dev/lock file
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
Summary
When portless is invoked as a child of turborepo (e.g.
portless frontend next devinside aturbo devpipeline), stopping the dev server with Ctrl+C leaves the spawned command (e.g.next dev) running as an orphaned process.Because the orphaned
next devprocess never exits cleanly, it doesn't remove its.next/dev/lockfile. On subsequent runs, Next.js detects the stale lock and refuses to start:The only workaround is to manually delete the lock file at
apps/frontend/.next/dev/lockbefore restarting.Root cause
spawnCommand(incli.js) spawns the child process withdetached: trueon non-Windows platforms:This places the child in its own process group. Portless registers SIGINT/SIGTERM handlers that call
killTree(child, signal)(which doesprocess.kill(-child.pid, signal)) and then immediately exits viaprocess.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:
detached: true), it's not reached. The child becomes 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: truefromspawnCommand. 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: truewas 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:process.exit()in the signal handler, giving the child time to complete async cleanup.detached: falseand rely on the shared group semantics.Reproduction
devtask that runsportless <name> next devturbo dev, wait for Next.js to startnext devprocess (or its/bin/shwrapper) remains running (ps aux | grep next)turbo devagain — Next.js refuses to start due to the stale.next/dev/lockfileEnvironment
detached: true)Related