macOS · Swift · Pier

lsof Says node. It Doesn't Say Which Project.

A Pier postmortem: the ports list was all node, python, com.docker.backend. Fingerprinting 40 dev services from full argv, pulling working directories via proc_pidinfo, and resolving container names through docker ps — three signals that answer what a port actually is.

Pier’s ports tab had an embarrassing problem: lsof tells you who’s listening, but the answers are all node, node, python, com.docker.backend. If you’re running three frontend projects, three rows of node tell you nothing. The real question is “which project’s Vite is on 3000,” not “there’s a node on 3000.”

2.0 splits that question into three orthogonal signals, each with its own data source.

Signal one: what service is this — argv fingerprinting

The process name carries no information, but the full command line does: node /work/shop/node_modules/.bin/vite is obviously Vite. After lsof yields the pids, one ps -o command= call pulls every argv in a single batch (~30ms), which then runs through a rule table:

Rule(executables: ["node", "bun"], #"(^|/)vite( |$)|/bin/vite"#,
     .init(id: "vite", displayName: "Vite", icon: "bolt.fill", category: .frontend)),
Rule(executables: ["node", "bun"], #"\bnext(-server)?( |$)|\bnext (dev|start|build)\b"#,
     .init(id: "nextjs", displayName: "Next.js", ...)),

Each rule filters in two layers: executable basename prefix match (node also covers node22, bun covers bun-1.x), then a precompiled regex (the table scans every port; recompiling regexes each pass is burning CPU for nothing). Rules are ordered narrow-to-wide by specificity; first match wins. Currently about 40 rules covering the usual suspects — Next.js, Vite, Django, Rails, Postgres, Redis — and each new rule takes five minutes, so the marginal cost of the table is near zero.

Signal two: who started it — source classification

“What is it” and “who started it” are orthogonal: the same Vite port might be your own npm run dev, or something an AI agent inside Cursor spun up. Hence a second axis:

enum SourceCategory { case project, aiTool, system, otherApp }

Classification is first-match-wins: owned by root/UID<500 → system; executable path inside a known AI tool’s .app bundle (Cursor.app, Claude.app, Windsurf.app… a whitelist) → aiTool; matches a service fingerprint, or cwd is in the user’s home project area → project; everything else → otherApp. The list sorts project to the top — your own stuff always floats first.

One honestly documented limitation: AI tools that live as VSCode extensions (Cline, Copilot) have no standalone .app; their ports belong to the host VSCode process and land in otherApp. Fixing that requires parent-process tracking, which isn’t worth it yet.

Signal three: which directory — and the Docker hole

Working directories come from the proc_pidinfo(PROC_PIDVNODEPATHINFO) syscall, batched across all pids in 10–50ms with no extra lsof forks. With a cwd, each port row shows its project path: click to reveal in Finder, right-click to open in Terminal or any installed editor — the editor list is probe-based, only offering .app bundles that actually exist on disk, with bundle-ID fallback for relocated installs.

Docker blocks that road entirely: the pid lsof sees is a proxy process like com.docker.backend, with zero container information. The workaround is a reverse lookup:

docker ps --format "{{.Names}}\t{{.Ports}}"
→ "shop_nginx_1   0.0.0.0:8080->80/tcp"
→ regex :(\d+)-> extracts host ports → [8080: "shop_nginx_1"]

Any port owned by a proxy process gets its chip overwritten with the container name. Two defensive details: docker ps is only forked if a docker/orbstack/vpnkit process actually exists in the process list (zero cost for people without Docker), and since the CLI waits forever when the daemon is down, a 6-second SIGKILL timeout treats a hang as “no containers.”

Assembly: three roads in parallel

The three signals are independent, so there’s no reason to serialize them. The lsof scan, the batched ps, the proc_pidinfo loop, and docker ps all run under async let; total refresh time equals the slowest leg. An early version ran the Docker query serially — a stuck daemon froze the whole ports list for 6 seconds. Lesson absorbed.

Takeaways

  • When the raw data has no information, find an orthogonal second source instead of digging harder in the same place. You can’t mine Vite out of a process name; argv hands it to you;
  • Don’t conflate classification axes. “What service” and “who started it” are separate enums, so the UI can filter on each and the rules evolve independently;
  • Every forked subprocess is a liability: first decide whether it needs to run at all (the proxy-process probe), then put a timeout on the ones that must (6s SIGKILL) — you don’t control external tools’ behavior, only how long you wait;
  • Write the cases you can’t detect into the code as comments instead of pretending coverage. The VSCode-extension limitation sits right next to the whitelist, in plain sight.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.