-
Notifications
You must be signed in to change notification settings - Fork 0
Process Detection
The dashboard reads OS state directly. No internal "list of servers we started".
-
ps -A→ every process -
lsof -iTCP -sTCP:LISTEN→ listening sockets, grouped by PID -
lsof -p PID -d cwd→ cwd for each PID with a listener -
git rev-parse --git-common-dir --abbrev-ref HEAD→ group worktrees by shared git dir
Implementation in src/servers.ts::fetchServers. Steady-state polls are mostly ps, lsof listener scan, portless list, and cheap cache checks.
The dashboard catches servers started in your terminal, via this extension, via any other tool. Anything listening counts. The source of truth is the OS, so we never go out of sync.
Earlier versions ran an embedded shell pipeline. The current code is ~3× faster and removes a class of shell-parsing bugs (awk/grep quoting, locale-dependent output, etc.).
Per-PID metadata (cwd, framework, runtime) is cached for the lifetime of the PID. The cache is validated against process start time so PID reuse cannot inherit stale metadata, and steady-state polls skip the cwd lsof query and tool-detection regexes.
Git project info is cached per cwd. The invalidation key is the worktree's HEAD file mtime, which changes on checkout, so each poll can replace a git rev-parse spawn per project with a single stat.
The dashboard's redundant-render guard compares PID, port, and branch. Branch is included because a checkout under a still-running server should update the row immediately instead of waiting for the process to restart.
A dev server's internal sockets must not become rows of their own. suppressHelperRows drops a listener on four signals, all of which require an OS-assigned port (at or above EPHEMERAL_PORT_MIN, 49152). That half never lies: nobody picks 51759 on purpose, so a process listening there is plumbing. A child on a configured port stays visible, which is what keeps workerd on 8787 under wrangler dev, or a Hydrogen storefront under shopify app dev, showing as the servers they are.
Given an ephemeral port, any of these hides the row:
-
Descent. The process is a descendant of another row's process, within
ANCESTOR_SCAN_DEPTH. The plain "forked a helper with port 0" case. - Same project. Some other row on a deliberate port has the same cwd.
- Orphaned. Its parent is init, so whatever launched it is dead.
- Mid-start. The caller says a start is in flight for this cwd.
Descent alone loses to reparenting. Miniflare's workerd is launched by an intermediate process that then exits, so workerd is adopted by init and the ancestor walk finds nothing above it but pid 1. Two of them per vite dev surfaced as extra servers of a SvelteKit + Cloudflare project.
They were not merely noisy. Restart on a helper row killed workerd and started a second dev server for the same folder, leaving the first running. Repeat and the project accumulates dev servers, each on the next port up, with a fresh pair of helpers each time. Observed on a real project as four rows where one belonged.
The second rule needs a living server to point at, and the nastiest case is the one where there isn't one. A project whose dev server died leaves its workerd behind holding an ephemeral port, and that lone orphan rendered as a project "running" one server on localhost:57018: reported as up when nothing of it was, with a Kill that did nothing, since orphaned workerd ignores SIGTERM. An ephemeral port plus a dead parent is not a dev server anyone started (added 2026-07-26).
A project mid-start has a window where its helpers are listening and its dev server is not, and the first three rules all recognise plumbing by a real server they can find: there isn't one yet. The pending start row is the missing evidence, so fetchServers takes an optional settlingCwds set and refuses ephemeral-port rows for those cwds outright. The dashboard passes its starts in flight; the menu bar builds the set from the persisted pending store (src/pendingStore.ts), ignoring entries past their deadline so an unloaded dashboard cannot hide rows beyond its own window. Putting the rule in the fetch rather than in the dashboard's render is what makes the snapshot, the menu bar count, and the recents feed all agree; it used to live in the render alone, and the menu bar could show the phantom row the dashboard was hiding. A caller that knows nothing about starts passes nothing and gets the other three rules unchanged.
What survives all four rules is an ephemeral-port listener with a living parent that we are not showing: somebody's own tool, which we have no business hiding. A row is the honest answer there.
Killing a dev server does not take its reparented helpers with it: they are nobody's children by then, so they keep running and holding their ephemeral ports until the machine restarts. Two more accumulated per restart.
Suppression alone could not cover this, and the reason is worth keeping. Rule 2 is only true while a real server is listening, and a restart is precisely the moment that stops being true. So the entire accumulated pile surfaced in the gap between the old server dying and the new one binding, then vanished when the new one came up. Observed as sixteen rows flashing in and out on the fourth restart of one project.
Two exports in src/servers.ts close it, sharing one implementation:
-
reapProjectHelpers(cwd)reaps right now or not at all. It runs at the top ofstartDevServer, so every spawn path, including a restart's respawn, clears the project's leftovers first; the project is expected to be down there, so any waiting would be dead latency on every start. -
reapProjectHelpersWhenDown(cwds)is for the kill paths: the dashboard's single, per-project, and Kill All flows, and the menu bar's Kill items. The dashboard's kills are a bare SIGTERM that returns before the server releases its port, and a reap taken at that instant sees a live server and declines, which silently no-ops the cleanup. So this variant polls in 300ms rounds (~2s budget), one listener snapshot per round shared across every folder being killed, reaping each folder as it goes quiet.
Both back out entirely for a cwd while any listener on a deliberate port still has it, so a project running two dev servers keeps the survivor's plumbing, and the victim test is a four-way conjunction: the cwd, an OS-assigned port, orphaned (parented by init, the same judgment rule 3 makes about what to hide), and named in REAPABLE_HELPERS, currently just workerd. The name gate exists because the first three are coincidences a tool of the user's own can hit together: anything nohup'd or daemonized from the project folder is init-parented too, and a standalone vitest --ui or a throwaway http.createServer(0) script deserves to keep running. workerd is the only helper ever observed leaking port-holding orphans; a future leaker gets its name added to the set. The orphan half still matters for the timing: every helper the reap exists for is an orphan by definition, since unix reparents to init the instant a parent exits, including a parent killed milliseconds ago.
The signal order is SIGTERM, a 300ms grace, then a signal-0 check and SIGKILL. The escalation is not optional politeness: orphaned workerd ignores SIGTERM outright (its shutdown path wants the supervisor that died to orphan it), verified on a live one that sat through SIGTERM and went down on SIGKILL. When there is nothing to signal, the grace wait is skipped, which is the common case since most projects have no helpers and every spawn reaps first.
Reaping is hygiene layered on top of the kill the user asked for, so every failure inside it is swallowed. It must never turn a successful kill into a failed one.
A PID can listen on several sockets. The displayed port is the lowest listener, and lanExposed is tied to that displayed port's socket, not any socket owned by the process. Otherwise a loopback-only HTTP server with a wildcard HMR socket could incorrectly offer Copy Network URL.
Platform primitives are isolated in named functions: listProcesses, listListeners, listCwds, fetchAliases. Swap them for Windows equivalents and the aggregation logic stays unchanged.
Suggested mapping (untested):
| macOS | Windows |
|---|---|
ps -A |
Get-CimInstance Win32_Process |
lsof -iTCP -sTCP:LISTEN |
Get-NetTCPConnection -State Listen |
lsof -p PID -d cwd |
Win32_Process cwd via WMI |
portless list (via zsh) |
portless list (via PowerShell) |
killServer (SIGKILL + process.kill(pid, 0)) already maps cleanly to TerminateProcess/OpenProcess on Windows.
- Per-server CPU/memory stats via extra
pscolumns rolled up over the process tree (Roadmap Phase 2, foundation F2).