macOSSwiftPier

Stopping a Process That Keeps Changing — TOCTOU and Continuous Checkpoints in a Cleanup Action

A Pier 3.0 postmortem: the green verdict uses a snapshot from the moment of diagnosis, but between the user clicking cleanup and the signal firing, the process can grow new connections and new children. On shrinking that check-to-use window to the minimum with continuous checkpoints, a 2-second evidence deadline, and re-verifying the kernel start time right before the signal.

Pier 3.0 judges a dev residue “safe to stop” using evidence collected at the moment of diagnosis — no live connection, process tree orphaned, identity matches. But there’s a classic concurrency trap hiding here: there’s time between the judgment and the action. The user sees a green card, thinks for a few seconds, clicks “clean up safely,” and then SIGTERM actually fires — and in that window, that “idle” dev server could have just been connected to by a browser, or just forked a new child. Stopping it is exactly the “the page the user was looking at suddenly dies” from the network-monitor postmortem.

This is TOCTOU (Time-Of-Check to Time-Of-Use). For a tool that will actually kill a process, that window is the crux of safety. This post is about shrinking it to the minimum.

The strongest guard: live connection

First, the most important criterion. The strongest safety guard in 3.0 is live-connection detection, and the rule is one line:

If any established connection appears on the listening port within the sampling window, the resource cannot go green.

It blocks exactly the class that’s easiest to wrongly kill and costliest to lose: the page the user is looking at in their browser, the HMR / WebSocket the frontend is connected to, the endpoint an API debugging tool is hitting, a service another local process is using. If someone’s connected, never touch it — even if it looks like the most ordinary Vite dev server.

One scan isn’t enough; you need continuous checkpoints

The naive implementation: scan connections once at diagnosis, put it in green if none, kill directly on click. But that lands squarely on TOCTOU — the conclusion of that one diagnosis scan could well be stale by click time.

3.0’s approach spreads “the check” from a single point into a chain of continuous checkpoints, sandwiching the states that might change:

  1. At diagnosis refresh, collect connection evidence to decide whether the resource shows as green;
  2. Re-diagnose after the user confirms — don’t trust the card from a few seconds ago. If the new diagnosis fails, the project’s safety level changed, the selected resource changed, or identity evidence doesn’t match, refuse to execute and send the user back to the latest state;
  3. Before sending each signal, sandwich the changeable network state with “ports and connections → sample CPU/IO activity → ports and connections again” — doing the slow activity sample only between two network checks, ensuring connections are clean both before and after the sample;
  4. After the second network check, keep verifying the process tree and the stable identity of every still-alive node across the whole component;
  5. From the first check to the actual signal, no more than 2 seconds. After the slow identity check completes, do one final round of port, connection, and process-tree recheck, hold this 2-second evidence deadline, and finally re-verify the target’s start time directly from the kernel before firing immediately once it checks out.

If any checkpoint finds an established connection, finds the connection data source failing, or finds an identity or process-tree change — the whole process tree gets no signal. This isn’t “check once then act,” it’s “recheck at every step right up against execution, and abandon the whole thing the moment the window goes dirty.”

Why “re-verify the start time directly from the kernel”

That “re-verify the start time from the kernel before the signal” at the end of step 5 deserves its own note — it plugs the most insidious TOCTOU of all: PID reuse.

A PID gets recycled and reassigned by the OS. Suppose at diagnosis PID 4242 is that Vite in your project that should be stopped — and right before you fire the signal, it exits on its own, and the system hands 4242 to a freshly started, entirely unrelated process — possibly something important. Now kill(4242, SIGTERM) hits an innocent.

The defense: a process’s true identity isn’t the PID, it’s the tuple (PID, stable start time). The start time is recorded by the kernel, unforgeable, unchanged for the process’s whole life. So at the last instant before firing, ask the kernel directly: “is 4242’s start time still the one I recorded?” — fire only if it matches; a mismatch means the PID has changed hands, so abandon immediately. Putting this re-verification as close as possible to the kill is precisely to make the window between “re-verification passed” and “signal delivered” nearly nonexistent.

The stop flow: leaf to root, recorded honestly

When the stop actually executes, there are a few more disciplines all in service of “prefer a miss over a wrong kill”:

  • Persist a snapshot before the signal. Before stopping, write the info needed for recovery to disk (detailed next post); if the snapshot write fails, send zero signals and error out — leaving no “stopped but unrecoverable” state;
  • Send SIGTERM leaf to root. Stop children before parents, avoiding a stopped parent respawning them or leaving zombies;
  • Record five outcomes honestly — stopped, still running, exited, skipped, failed. Especially: a node that already exited on its own before the action counts as “skipped,” never masquerading as “stopped.” Falsely reporting a “stopped” makes the user think the port was freed and they can start a new service, and then it collides;
  • Rescan after stopping. If a new process appears in the same project, or a replacement reusing the original PID, or a process with the same command whose cwd can’t be confirmed, mark the result “partially stopped” and disable restart — better to tell the user “not fully stopped” than to fake success;
  • 3.0 doesn’t auto-escalate to SIGKILL. Gentle stop is the default and only automatic path; SIGKILL is reserved for an explicit advanced user action.

One more engineering discipline: the whole stop and subsequent restart run in a background task, never blocking the menu-bar main thread; refresh uses a generation/queue mechanism so a slow, stale scan result can’t overwrite the post-action new state.

Takeaways

  • The window between check and use is the crux of safety. Any design of “judge safe first, then act” must assume the world changes between judgment and action — especially when the action is an irreversible kill. Don’t trust the snapshot from a few seconds ago;
  • Spread one check into continuous checkpoints right against execution. Re-diagnose after user confirmation, sandwich the activity sample between two network checks before the signal, hold a 2-second evidence deadline over the whole flow — the smaller the window, the lower the odds a dirty state slips in;
  • Process identity is (PID, start time), not PID. A PID gets reused; keying on PID alone eventually kills the wrong target. Re-verifying the start time from the kernel at the point closest to the signal is the last gate against PID reuse;
  • Record honestly, don’t fake success. An exited node as “skipped” not “stopped,” a not-fully-stopped result as “partially stopped” — a cleanup tool that fakes one success sends the user to make the next action on it and then collide. Honest failure beats optimistic fake success.

The next post is the other half of this flow: what that “snapshot persisted before the signal” actually stores, and how to safely restart a stopped service without going through a shell.

Comments

  • Loading…

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