Commit Graph

1 Commits

Author SHA1 Message Date
James Anderson 9277b101b2 feat(dev): add dev server lock file (#1193)
* feat(dev): add dev server lock file

Write the running dev server's PID, port, and URL into
`node_modules/.cache/vinext/dev-lock.json`. When a second `vinext dev`
process starts in the same project directory, vinext reads the lock file
and prints an actionable error:

  Another vinext dev server is already running.

  - Local:        http://localhost:3000
  - PID:          12345
  - Dir:          /path/to/project

  You can access the existing server at http://localhost:3000,
  or run `kill 12345` to stop it and start a new one.

This is especially useful for AI coding agents, which frequently attempt
to start `vinext dev` without knowing a server is already running. The
structured error gives the agent the PID to kill the existing process or
the URL to connect to it — no manual intervention required.

If the recorded PID is dead (stale lock from a crashed run), the new
process takes over the lock automatically. Set `VINEXT_NO_DEV_LOCK=1`
to opt out entirely.

Behavior modeled on Next.js' dev server lock file:
https://github.com/vercel/next.js/blob/canary/packages/next/src/build/lockfile.ts

vinext uses a JSON file + PID liveness check (`process.kill(pid, 0)`)
rather than a native `flock()`. The race window on acquisition is
small and benign: at worst, two dev servers race and one fails to bind
its port.

* chore(dev-lockfile): unexport types only used internally

knip flagged FormatErrorOptions, AcquireOptions, AcquireSuccess,
AcquireFailure, and AcquireResult as unused exports. These are only
referenced inside dev-lockfile.ts itself, so drop the `export`
keyword to keep the public surface minimal.

* review(dev-lockfile): address self-review and bot feedback

- Capture ownerPid at acquire time and use it in release() instead of
  reading info.pid. Decouples release from update() so a future caller
  passing a different PID through update() can't trick release() into
  deleting another process's lock. Added a regression test.

- Document the TOCTOU window between readLockfile() and writeLockfile()
  inline so future contributors don't try to 'fix' the intentionally
  accepted race.

- Document the unreachable existing: undefined branch in
  formatAlreadyRunningError as a defensive fallback.

- Document the process.on('exit', ...) signal semantics — exit fires on
  graceful shutdown and after default SIGINT/SIGTERM handlers, but not
  on hard crashes. The next vinext dev will take over the stale lock.

- Set lock file mode to 0o600. Defense-in-depth: the PID is also
  discoverable via ps, so this isn't load-bearing.

- Use Vite's server.resolvedUrls.local[0] in cli.ts when building the
  post-listen appUrl. Vite substitutes 'localhost' for wildcard binds
  (0.0.0.0) so the URL is actually clickable. Also substitute 'localhost'
  in the pre-listen initial info for the same reason.

- Add tests:
  * exit listener is registered/deregistered correctly
  * update() with a different PID doesn't affect release() ownership
  * lock file is created with 0o600 on POSIX

* review(dev-lockfile): release on listen failure, preserve startedAt

Two issues caught in the bot re-review:

1. If `server.listen()` throws (e.g. strictPort and the port is taken),
   the dev() function previously threw without releasing the lock. The
   process.on('exit') handler still cleaned up afterwards, but in the
   brief window between listen failure and process exit, the lock file
   claimed a server was running at a port nobody was listening on. A
   concurrent `vinext dev` in that window would have shown a misleading
   'already running' error.

   Wrap createServer + listen in try/catch and call lockfile.release()
   on failure. The exit listener is still registered as a safety net
   for unexpected exit paths.

2. update() after server.listen() was setting `startedAt: Date.now()`,
   which overwrote the original acquisition timestamp. `startedAt` is
   meant to reflect when the process started (for 'how long has this
   server been running?' debugging), not when the URL was resolved.

   Capture `startedAt` once at acquisition time and pass the same value
   into update(). Added a regression test.

* feat(dev-lockfile): move lock file to .vinext/dev/lock.json

Previously the lock file lived at node_modules/.cache/vinext/dev-lock.json.
.vinext/ is already the established convention for project-local vinext
state — the fonts plugin uses .vinext/fonts/ to cache self-hosted Google
Fonts, and the monorepo's own .gitignore already excludes .vinext.

Benefits:
- Doesn't pollute node_modules (which package managers may aggressively
  clear during install / pnpm prune).
- Co-located with other vinext state, so users find it where they
  expect.
- Mirrors Next.js' .next/dev/lock layout more closely.

Follow-up: vinext init does not currently add .vinext to user
gitignores. That's a separate hygiene improvement also relevant to the
fonts cache.
2026-05-15 09:53:02 +01:00