Engineering

Killing a process is harder than it looks.

By Albert 8 min read

Localhost Explorer has one button that does something irreversible: Stop. Everything else just reads your machine. Stop sends a signal. Get it wrong and you've killed the wrong process — on someone else's Mac, for $5. So most of the engineering went into the parts you never see.

This is the honest version of how it's built. No architecture-astronaut diagrams, no "10x" anything. Just the four places the floor turned out to be lower than it looked, and what we did about it.

1. The race condition in every naive process killer

Here's the bug almost every "kill a process" tool ships with, including ones you've paid real money for.

You scan the system. You find PID 4823 listening on :8000. You draw it in a list. The user reads the row, thinks about it, and four seconds later clicks Stop. You send:

kill -TERM 4823

Between the scan and the click, 4823's process exited on its own. The kernel — which recycles PIDs aggressively — handed 4823 to something completely unrelated. A Mail helper. A database. Their editor.

You just killed it.

This is a classic time-of-check to time-of-use race, and the seconds a human spends reading a confirmation dialog are an enormous window for it. For an app whose entire promise is "stop the right thing," this is the one failure that isn't allowed to happen.

The fix is to re-verify identity at the moment of use, not the moment of check. Right before the signal, we re-read the live command line for that PID and compare it to what we recorded at scan time:

func assertSafeToKill(_ service: LocalhostService) throws {
    let current: String
    do {
        current = try Shell.run("/bin/ps",
            ["-p", String(service.pid), "-o", "command="])
    } catch {
        // ps exits non-zero when the PID is gone. The process we
        // wanted already died — refuse to signal a recycled PID.
        throw ShellError.failed("…no longer running. Refresh and try again.")
    }

    guard processIdentityMatches(
        recordedCommand: service.commandLine,
        recordedName: service.name,
        currentCommand: current
    ) else {
        throw ShellError.failed("PID now belongs to a different process — "
            + "macOS recycled it. Refresh and try again.")
    }
}

The comparison lives in a pure function so it's unit-testable without spawning anything — exact command-line match when we recorded one, leaf-executable-name match as a fallback. Eight tests cover the cases that matter: exact match passes, recycled-to-different-process fails, same-binary-different-args fails, whitespace drift tolerated.

Now the part most posts skip. This does not fully close the race. There's still a sub-millisecond window between the verify and the signal. Truly closing it needs a process handle — kqueue's NOTE_EXIT, or a pidfd on Linux — which macOS doesn't cleanly hand you for an arbitrary PID you don't own. So we did the proportionate thing: we shrank the window from "seconds the user spent reading" to "two back-to-back operations," and we wrote that limitation down instead of pretending it's airtight.

If a tool tells you process-killing is simple, it has this bug.

2. "Python" is the wrong name for a process

Run a dev server and look at what's holding the port: Python. That's the interpreter binary. It tells you nothing. The thing you actually started was python3 -m http.server 8000, or Django, or Uvicorn — and that's what you need to see before you decide to kill it.

So when the binary is a generic runtime — python, node, ruby, bun, deno — we don't trust the binary name. We read what it's actually executing:

// inside the command line, after the interpreter token:
if token == "-m", i + 1 < tokens.count {
    let module = tokens[i + 1]          // "http.server"
    return InterpreterIdentity(
        title: prettyModuleName(module), // → "http.server", not "Python"
        restartCommand: canonical,       // the line to re-run later
        …
    )
}

// <script>.py / .rb / .js → use the script's basename
if lower.hasSuffix(".py") || lower.hasSuffix(".rb") || lower.hasSuffix(".js") { … }

// `-c "inline code"` → we can't summarise it honestly. Bail, don't guess.
if token == "-c" { return nil }

The detail that makes this trustworthy rather than clever: when we can't confidently name something, we say so and fall back, instead of inventing a label. A wrong-but-confident name on a Stop button is worse than an honest "unknown."

The same identity then flows everywhere — the row, the Stop dialog title, the "keep my Mac awake until this exits" picker. One source of truth for "what is this, really."

3. Let the kernel do the work

One feature: keep the Mac awake until a specific process finishes — your build, your training run, your long test. The naive version polls: every second, is PID 4823 still alive? It's wasteful and it drifts.

macOS already solved this. caffeinate takes a -w flag that waits on a PID and exits when it does. So the entire feature is:

func start(watchingPID pid: Int, label: String) {
    startCaffeinate(
        arguments: ["-d", "-i", "-w", String(pid)],
        watchedLabel: label
    )
}

We spawn caffeinate -d -i -w <pid> and walk away. The kernel watches the PID. When the process dies, caffeinate dies, our termination handler fires, the menu-bar icon flips back. No polling and no timers on our side — the kernel does the watching, through code Apple already wrote and tests.

4. "Nodebox" is not Node

Identifying what a process is — Redis, Postgres, a Vite dev server — starts with matching known names against the command line. The lazy version is text.contains("node"). That also matches "Nodebox", "node_modules", and a dozen apps that merely have those four letters somewhere in their path.

So short, collision-prone names go through a word-boundary check instead:

// "node" matches "/usr/bin/node app.js"
// "node" does NOT match "/Applications/Nodebox.app/…" or "node_modules/…"
static func wordMatch(_ haystack: String, _ needle: String) -> Bool {
    // …scan for the needle, but only accept a hit when the characters
    // on both sides are word boundaries (not letters/digits/underscore).
}

It's a small check, but it's the difference between an app that quietly mislabels a process and one that doesn't. The misclassifications you never notice are the ones that erode trust the most, because the user can't tell the tool is wrong — they just slowly stop believing it.

Why it isn't sandboxed

Localhost Explorer isn't on the Mac App Store, and it never will be. The whole job is reading other processes' ports, working directories, launchd labels, and Homebrew service names — then acting on them. App Sandbox exists specifically to prevent that. You cannot build this app correctly inside the sandbox; you can only build a worse version that pretends.

So it's a direct download, signed and notarized, updated through Sparkle. That's a deliberate trade: more responsibility on us to be trustworthy, in exchange for an app that can actually do the thing. Everything stays on your machine — no account, no telemetry, no scan results leaving the Mac. When the entire pitch is "let me read your processes," keeping everything local is the minimum you owe people, so we don't list it as a feature.

The shape of it

Under the hood it's plain, boring Swift. Each tool — keep awake, wait for a port, hold a port, restart a managed service, stop everything in a folder — is its own small @MainActor object with observable state and a single responsibility. They talk to the menu-bar icon through typed notifications, so nothing reaches into anything else. The process intelligence is a tested decision layer, not a pile of guesses. There's CI, there's a test suite, there's a written list of what's still fragile.

None of that is glamorous, but it's what separates a weekend script from something you'd trust with kill on your own machine.

That's the whole bet: the unglamorous parts are the product. A list of localhost ports is a five-line shell command. Knowing which one is safe to stop — and not killing the wrong thing when you do — is the part worth $5.

See it on your Mac.

$5. One Mac. Yours to keep. Local-only, no telemetry, no account.