Why I Looked into Replacing lsof with a Rust-Powered 'Sniper' Button
Contents
When localhost:3000 or localhost:8000 is already taken during local development, everything stops for a moment.
Some leftover Vite, FastAPI, ComfyUI, or random test server is still alive from earlier.
The usual routine goes like this:
lsof -i :3000
kill -9 <PID>
A post on DEV Community, Why I Replaced lsof with a Rust-Based “Sniper” Button, covers how Recoil—a Tauri-based tray app—eliminates this friction.
The original article says “I replaced lsof,” but looking at the GitHub implementation, it’s not quite that.
Recoil doesn’t ditch lsof entirely; the Rust side calls lsof -iTCP -sTCP:LISTEN -P -n and makes the results GUI-friendly.
What actually got replaced isn’t lsof itself, but the manual sequence of checking ports, finding PIDs, and terminating processes.
It’s Not About Memorizing lsof—It’s Context Switching
lsof and kill aren’t difficult commands.
But when they interrupt your flow repeatedly, they become annoying.
In a setup that combines FastAPI, WebSocket, Tailscale Serve, and CLI workers—like what I run for kana-chan updates—multiple listening ports pile up locally.
In Kana Chat v2 Architecture Changes, I was routing localhost:8000 through Tailscale Serve for HTTPS. With configurations like these, you want to see which process is holding which port right now.
ComfyUI is the same story. Repeated headless API runs and test launches leave old processes behind, blocking the next startup. In testing like Running WAI-Anima v1 on an RTX 4060 Laptop via ComfyUI API, the time lost to environment plumbing before you even get to the actual generation is the most frustrating part.
Recoil targets exactly this “not the port again” moment, letting you squash it from the tray. It lists ports, PIDs, and process names, and you terminate with the Sniper Button.
flowchart TD
A["Start dev server"] --> B{"port already in use"}
B --> C["Check listening ports in Recoil"]
C --> D["Confirm PID and process name"]
D --> E["Terminate with Sniper Button"]
E --> F["Restart dev server"]
Where Rust Helps and Where It Doesn’t
Recoil’s stack: React 19 + Vite on the frontend, Tauri v2 for desktop integration, Rust on the backend.
The README says it uses sysinfo and lsof.
The rough division of responsibility looks like this:
| Area | Tech Used | Role |
|---|---|---|
| UI | React, Tailwind CSS, Lucide | Port list, search, Kill button |
| Desktop integration | Tauri v2 | Tray app, updater, OS hooks |
| Process info | Rust, sysinfo | Get process name, CPU, memory from PID |
| Listening port detection | lsof | Enumerate TCP LISTEN ports |
| Termination | kill -9 | Send SIGKILL to PID |
A note here: this isn’t a “Rust is faster than lsof” story.
At least in the current public code, port enumeration still calls lsof as an external command.
Where Rust actually matters is the lightweight resident app, Tauri IPC, process info formatting, and the non-blocking UI design. Standing up a 150MB Electron app as a tray resident vs. using Tauri with the OS’s native WebView—the original author’s call makes sense.
This direction is close to what I explored in Can Tauri Build a Multi-Pane SFTP Client?. Whether it’s file transfers or port monitoring, you build the UI with web tech and push OS-level operations to Rust. A thin native wrapper for things browsers can’t easily touch.
The Sniper Button Is Nice, but kill -9 Is Harsh
Recoil’s “Sniper” is a satisfying name, but the current implementation uses kill -9.
That’s SIGKILL—no graceful shutdown window for the target process.
For dev servers, that’s usually fine. But if you catch a database, a queue, a tool mid-file-write, or a training/generation process, cleanup handlers won’t run.
If I were to use this in my own environment, here’s what I’d check:
| Check | Why |
|---|---|
| Can I see process name and launch args, not just PID? | node or python alone isn’t enough to tell processes apart |
| Can I send SIGTERM first? | Safer for processes that handle graceful shutdown |
| Can I filter by port? | I want to quickly see 3000, 5173, 8000, 8188 |
| How does it handle processes owned by other users? | On macOS, you can’t kill another user’s process without privileges |
| Can I disable telemetry? | v1.4.3 includes Aptabase event tracking |
Recoil’s site lists v1.4.3 changes including privacy-aware analytics, tokio-based async processing, and telemetry for launch success and process operations. For a personal local dev tool, I’d want to verify what’s being sent before installing.
A CLI One-Liner Often Suffices
Whether you need a GUI like Recoil depends on frequency.
If ports only block you occasionally, a shell function is enough:
portkill() {
local port="$1"
local pid
pid=$(lsof -tiTCP:"$port" -sTCP:LISTEN)
if [ -z "$pid" ]; then
echo "no listener on port $port"
return 0
fi
echo "$pid"
kill "$pid"
}
Send SIGTERM first; escalate to kill -9 only if needed.
For multiple PIDs or when you want to confirm process names, extend this function a bit.
On the other hand, a GUI lets you see “which ports are currently occupied” at a glance. When you’re bouncing between kana-chan, an LLM API proxy, ComfyUI, Vite, Astro, and a Playwright test server, the problem isn’t a single PID—it’s visibility over your entire local environment.
Recoil puts that visibility in the tray.
It didn’t magically replace lsof with Rust, but if it kills the willpower cost of typing commands every time, that’s practical enough.
Services That Love to Collide on Default Ports
Port conflicts aren’t just about leftover processes. Different tools using the same default port number is surprisingly common.
The most contested is :3000—Next.js, Create React App, Ruby on Rails, and Grafana all default to it.
Run two frontend projects in parallel and you’ve got a collision.
:5000 is also trouble.
It’s the default for Flask and uvicorn, but since macOS Monterey (12.0), AirPlay Receiver claims this port.
You start Flask, get Address already in use, run lsof, and find ControlCe staring back.
System Settings > General > AirDrop & Handoff > turn off AirPlay Receiver to free it. But if you don’t know that, you’ll burn time.
:8000 is too generic—Django, FastAPI, python -m http.server—so collisions are constant. :8080 sees Spring Boot, Jenkins, and various proxies fighting over it.
Common collision-prone ports:
| Port | Default Services | Notes |
|---|---|---|
| 3000 | Next.js, CRA, Rails, Grafana | Most contested |
| 4321 | Astro | Relatively peaceful |
| 5000 | Flask, uvicorn | Collides with macOS AirPlay |
| 5173 | Vite | Vite-exclusive, mostly safe |
| 5432 | PostgreSQL | Homebrew auto-start can silently occupy |
| 6379 | Redis | Same as above |
| 8000 | Django, FastAPI, python http.server | Too generic, always risky |
| 8080 | Spring Boot, Jenkins, various proxies | Java + CI conflicts |
| 8188 | ComfyUI | Fixed for AI image generation |
| 8888 | Jupyter Notebook | Fixed for data analysis |
| 11434 | Ollama | Fixed for local LLMs |
Databases deserve special attention.
When you install PostgreSQL or Redis via Homebrew, brew services may configure them to auto-start at login.
If a port is occupied and you didn’t start anything, check brew services list.
Trying to spin up the same DB in Docker Compose and hitting a conflict with the Homebrew-managed service is a classic gotcha.
Tools That Silently Switch Ports Are the Worst
How tools handle port collisions varies wildly.
Vite and Webpack Dev Server auto-find the next available port when their default is taken. Vite goes 5173, 5174, 5175 in sequence. Convenient, but forget to close a previous session and suddenly three Vite instances are running.
Next.js asks in the terminal if you want a different port.
Flask, Rails, and Django just die with Address already in use.
Auto-avoidance seems friendlier at first glance. But it’s actually the trickier behavior. The browser doesn’t know the port changed, so reloading an old tab hits the previous instance. You spend time wondering “why aren’t my code changes showing up?” before realizing you’re on a different port.
Recoil’s port list helps here. If you see three Node processes on 5173–5175, snipe the old two and move on.
Do Programmers Worldwide “Kill” Their Processes Too?
Talking about processes makes conversations sound violent. “Kill the children before the parent.” “There’s a zombie left.” “The orphaned process was adopted by init.” Sounds like something that’d get you reported, but in UNIX-like OSes, these are all official terms.
The moment POSIX defined SIGKILL as a signal name, “kill” was baked into the specification.
The kill command, parent process, child process, orphan process, zombie process.
These aren’t just English metaphors—they’re OS design terms exported worldwide.
Looking at technical documentation across languages, every one of them uses a direct translation or transliteration of “kill.”
| Language | Kill a process | Parent / Child | Zombie | Orphan |
|---|---|---|---|---|
| English | kill a process | parent / child | zombie | orphan |
| Japanese | プロセスを殺す | 親 / 子 | ゾンビ | 孤児 |
| Chinese | 杀死进程 | 父进程 / 子进程 | 僵尸进程 | 孤儿进程 |
| French | tuer un processus | père / fils | zombie | orphelin |
| German | Prozess töten | Elternprozess / Kindprozess | Zombie | verwaist |
| Spanish | matar un proceso | padre / hijo | zombi | huérfano |
| Russian | убить процесс | родительский / дочерний | зомби | сирота |
| Korean | 프로세스를 죽이다 | 부모 / 자식 | 좀비 | 고아 |
Everyone’s killing.
The violent vocabulary goes beyond “kill.” Fork bomb (flooding the system by infinitely spawning processes), deadlock (a standstill where nothing can move), fatal error, reap (harvesting zombie processes). Recoil’s “Sniper Button” sits squarely in this tradition.
The push around 2020 to replace master/slave with primary/replica is still fresh in memory. GitHub’s default branch name, Python’s internal terminology, database replication nomenclature. Terms once deemed “impossible to change” actually changed across a wide range of the industry.
kill, zombie, orphan—nobody has touched these yet. It’s hard to come up with alternatives, and renaming POSIX signal names would break compatibility everywhere. But people said the same thing about master/slave.
Can programmers keep “killing” unwanted processes going forward? Pressing the Sniper Button, I find myself wondering just a little.