#411·portless

Self-daemonizing commands leave an orphaned server and a dropped route that `prune` cannot find

Author: danielserranohCreated Sep 5, 2026Updated Sep 7, 2026

Summary

When the wrapped command self-daemonizes — starts a server, prints its address and exits 0 — portless treats the exit as "the dev server stopped". It deregisters the route and exits, but the server is still running. The result is an orphan that:

  • holds the port, so the next portless run in that project hits a port conflict;
  • is invisible to portless prune, because the route is already gone, so there is no record to prune;
  • leaves https://<name>.localhost returning 404 while the server is perfectly healthy on its port.

Recovering means finding the process by port (lsof -i :<port>) and killing it by hand — exactly the port bookkeeping portless exists to remove.

This is not hypothetical: Astro 7 self-daemonizes whenever it detects an agentic environment, which makes portless run astro dev unusable for the "and agents" half of portless's own pitch.

Reproduction (no framework needed)

fake-dev.sh — mimics any CLI that self-backgrounds:

bash
#!/bin/sh
node -e "require('http').createServer((_,r)=>r.end('ok')).listen(process.env.PORT||3000)" &
echo "Server running at http://127.0.0.1:${PORT:-3000} (pid $!)"
exit 0
bash
$ chmod +x fake-dev.sh
$ portless reprodemo ./fake-dev.sh
-- reprodemo.localhost (auto-resolves to 127.0.0.1)
-- Using port 4072
  -> https://reprodemo.localhost
Running: PORT=4072 ... ./fake-dev.sh
Server running at http://127.0.0.1:4072 (pid 24343)

$ portless list
No active routes.                     # route dropped

$ ps -o pid,ppid,command -p 24343
24343  1  node -e require('http')...   # PPID 1 — orphaned, still listening

$ curl -sk -o /dev/null -w '%{http_code}\n' https://reprodemo.localhost/
404                                   # named URL dead

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4072/
200                                   # server very much alive

$ portless prune
No orphaned routes found.             # cannot help: the route is already gone

$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:4072/
200                                   # still there

Real-world trigger: Astro 7 + any coding agent

Astro 7 backgrounds its dev server automatically when it detects an agentic environment. From astro/dist/cli/agent.js:

javascript
import { detectAgenticEnvironment } from "am-i-vibing";
function isRunByAgent() {
  return detectAgenticEnvironment().type === "agent";
}

and astro/dist/cli/dev/index.js:

javascript
const agentDetected = !process.env.ASTRO_DEV_BACKGROUND && isRunByAgent();
const wantsBackground = !!flags.background || agentDetected;

So the same package.json script behaves differently depending on who runs it:

Who runs portless run astro dev Astro portless
A person in their terminal stays in foreground works as designed
A coding agent (CLAUDECODE, Cursor, …) daemonizes, exits 0 drops the route, orphans the server

The failure is invisible until someone opens the named URL and gets a 404, or the next start hits the busy port. In our case a human and an agent were sharing one project, and the agent's orphan blocked the human's npm run dev with a port conflict on a port neither of them had chosen.

Why this seems worth fixing rather than documenting

portless describes itself as "Replace port numbers with stable, named .localhost URLs. For humans and agents." Agent-run dev servers are a stated use case, and agent-aware tooling that self-daemonizes is becoming more common, not less — Astro ships it on by default today.

Possible directions

Some are cheap, some less so; listing what we considered rather than prescribing:

  1. Don't tear the route down on child exit alone. Before deregistering, check whether the assigned port is still listening. If it is, the "server" outlived its launcher — keep the route (it still works) and warn.
  2. Track by port, not only by child PID, so portless prune can find and clean these. Right now the orphan is unreachable through any portless command.
  3. portless alias as the documented escape hatch. A static route survives this by construction. Worth calling out in the README for self-daemonizing CLIs, whatever else changes.
  4. Detect and warn when the wrapped command exits 0 within a second or two while its port is listening — that pattern is almost always self-daemonization, and a one-line hint would have saved the diagnosis.

We ended up at (3) plus a wrapper script that reads the real port back from the framework and points the alias at it, which works for both humans and agents. Happy to share it if useful.

Possibly related: #322 (orphans when the parent is force-killed by turbo) and #382 (routes.json emptied when a client is killed before cleanup). Both are about the parent dying; this one is about the child leaving on its own, so the existing prune path doesn't apply.

Environment

portless 0.13.0
astro 7.3.1
am-i-vibing 0.5.0
node v22.22.1
OS macOS 26.6.2