#382·portless

~/.portless/routes.json can end up corrupted or emptied when a client is killed before its exit cleanup finishes

Author: jelicalCreated Aug 13, 2026Updated Sep 2, 2026

Summary

Running a dev script through portless from an IDE run/debug configuration (e.g. WebStorm) spawns a chain like WebStorm -> yarn -> portless -> next dev. Stopping the debug session kills that whole tree. Instead of just that one route entry being cleared, ~/.portless/routes.json is sometimes left corrupted (invalid JSON) or completely emptied — other apps' routes disappear too, not just the one that was stopped.

Reproduction

  1. package.json: {"scripts": {"dev": "portless myapp next dev"}}
  2. Run it via an IDE debug configuration (or anything that spawns portless through an intermediate shell/package-manager process and force-kills the whole tree on stop).
  3. Stop the debug session a handful of times.
  4. cat ~/.portless/routes.json — occasionally invalid JSON, or [] even though other apps are still running.

Root cause

Two independent issues compound:

  1. RouteStore.saveRoutes (packages/portless/src/routes.ts) writes routes.json in place with a single fs.writeFileSync. That's not atomic — a process killed mid-write() leaves a truncated file. loadRoutes then fails JSON.parse, logs "Corrupted routes file", and returns [].
  2. The exit-time cleanup path (spawnCommand's SIGINT/SIGTERM handler in cli-utils.ts, runApp's onCleanup in cli.ts) removes the route synchronously, including RouteStore.acquireLock(), which retries via a genuinely blocking Atomics.wait for up to 15 seconds under lock contention. An IDE's stop/debug action typically escalates from SIGTERM/SIGINT to an uncatchable SIGKILL after a short grace period. If that escalation lands mid-write or mid-lock-wait, the process dies mid-operation instead of completing or safely aborting.

Together: a slow/interrupted cleanup plus a non-atomic write turns "one dead client's route should be removed" into "the whole file is corrupted."

Suggested fix

  • Make saveRoutes atomic: write to a temp file in the same directory, then fs.renameSync over the target, so an interruption at any point leaves either the old file or the new one intact, never a torn one.
  • Have the proxy itself periodically sweep dead-PID routes (it already has pruneStaleRoutes(), used today only by portless prune), rather than relying solely on the exiting client's own handler completing.

Happy to open a PR for both.