Skip to content

detached: true in spawnCommand causes orphaned child processes when parent is force-killed by turbo #322

Description

@neefrehman

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:

  1. 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.
  2. Turbo sends SIGTERM to portless's process group. Portless's handler fires and forwards the signal to the child's separate group.
  3. 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.
  4. 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

  1. Set up a turbo monorepo with a dev task that runs portless <name> next dev
  2. Use turbo >= 2.9.7
  3. Run turbo dev, wait for Next.js to start
  4. Press Ctrl+C
  5. Observe that the next dev process (or its /bin/sh wrapper) remains running (ps aux | grep next)
  6. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions