macOSSwiftPier

'Try Restart' Is Not 'Undo' — Shell-Free Restart and a Crash-Recovery Ledger

A Pier 3.0 postmortem: after stopping a dev server, restarting it looks simple and is deeply trapped. Why the button is 'Try restart' and not 'Undo,' why restart must never go through a shell, and a recovery design that tolerates both its own crash and a corrupt ledger.

After Pier 3.0 stops a dev residue, it offers a “Try restart.” Sounds like a small convenience — just run the command you killed again. Actually building it, it’s the most deeply trapped part of the whole cleanup flow: it involves faithfully capturing the process command, executing it safely without a shell, and a persistence design that tolerates both “Pier itself crashed” and “the recovery ledger got corrupted.”

Why “Try restart,” not “Undo”

Framing first. This button is called Try restart, and it’s deliberately not called “Undo.”

Because it can’t make the promise “Undo” implies. The original process may depend on a pile of things Pier can’t capture and can’t rebuild: environment variables exported in the shell, a one-time temp token, state passed down from the parent, login session context, temp files generated at startup. All Pier can save is the command and the cwd — it can’t guarantee a perfect restore.

Calling it “Undo” is making the user a promise you’ll break; calling it “Try restart” is honestly saying “I’ll do my best to start another one with the same command, but I don’t guarantee it’s identical to the original.” Product honesty comes before feature polish — an “Undo” button that doesn’t live up to its name destroys trust the first time it fails.

Restart must never go through a shell

The most important technical decision: restart does not go through shell interpretation.

The trap is that “the command” has two forms. What you see in ps is a space-joined string, e.g. node /path/to server.js --port 3000. If, to restart, you hand that string to sh -c, you’ve re-handed it to the shell for interpretation — and any space, quote, ;, or $() in it gets treated as syntax. A project with a space in its path, a command with a semicolon in an argument — best case it won’t start, worst case it executes something you didn’t intend. For an automatic restart feature, that’s an unacceptable attack surface.

The right way bypasses the shell and takes the real argv boundaries from the kernel:

  • Capture the process’s true argv array from KERN_PROCARGS2 — argument boundaries recorded by the kernel, not guessed. The snapshot saves both: a command string for display and the argv array for execution;
  • On restart, resolve the argv into “absolute executable path + argument array” and hand it to Process to launch directly. That way spaces, quotes, and ; in arguments are all just plain literals, interpreted by no shell;
  • Reject several dangerous inputs: missing or corrupt argv, bare environment-variable assignments (starting with FOO=bar), and sh / bash / zsh / fish interpreters directly or wrapped in env — no “restart a shell” path.

The environment is tightened too: an explicit PATH is authoritative, with no sneaky appending of Homebrew or nvm paths; only when the original process had no explicit PATH at all does it complete a GUI app’s slim environment using the captured path plus Homebrew and common nvm directories.

Preventing duplicate instances: check once more before launch

“Try restart” has another hazard — spawning a duplicate instance. The user may have already started the service back up by hand, and Pier starting another one is a double.

So before launching, check two more things: whether the original port is already occupied, and whether an equivalent process with the same cwd and normalized argv is already running. Either hit means don’t launch. And here too it fails closed: if it can’t even get the argv of the suspected duplicate to confirm it’s the same one, it would rather not launch than risk a double.

The crash-recovery ledger: tolerating Pier’s own death

The most hardcore part: what if Pier crashes, or gets force-quit, right at the moment it has “already initiated a restart but not yet confirmed the outcome”?

This rides on a persistent recovery ledger plus a cross-process lock. The flow:

  1. On restart click, persist the state as restartInProgress inside a cross-process project lock, then do the conflict check and launch;
  2. Only after launching does it rewrite the state to restartAttempted or restartFailed.

That order is key. Writing restartInProgress before launching means: even if Pier crashes at the instant of Process.run(), the ledger already holds the record “this restart may have started the target.” After the app restarts and reads restartInProgress, it knows the last attempt may have already launched the target — so it shows only an “unconfirmed” state and never auto-retries. Because auto-retry = possible double, and a double in the “may already be running” case is exactly what we’re avoiding.

That lock serializes all restarts for the same project by normalized project cwd, not by the snapshot’s UUID. This both prevents “synonymous but differently-written commands” from running concurrently and prevents “same project, different commands” from each launching one and creating duplicate services. The full argv serves separately as a second-stage target-identity check.

A corrupt ledger must fail closed too

The ledger itself can go wrong — a bad disk write, a mangled format. The rule here is consistent with all of 3.0:

  • ledger file absent: treat as empty history, fine;
  • file present but unreadable/undecodable: must fail closed and preserve the raw bytes — you can’t treat an unreadable ledger as empty, which could drop the critical record “there’s an unconfirmed restart”;
  • a corrupt ledger entry must be isolated — one bad entry can’t junk the whole ledger along with its other intact records.

And a few red lines to guard against overclaiming:

  • old snapshots without verified restartCommands are audit records only, and can’t synthesize a command to restart from;
  • Process.run() success means only that an attempt was initiated, not that the service came up. The UI must show yellow “attempted,” not green “restored”;
  • 3.0 only auto-restarts exactly one independent root entry. A multi-root launch can’t guarantee atomicity (what if half of it started and then crashed), so the button simply isn’t offered.

Takeaways

  • Don’t put a promise in the name you can’t keep. “Undo” implies “back to before,” but the environment, tokens, and temp state a process depends on often can’t be rebuilt; calling it “Try restart” writes the uncertainty into the button’s name, which beats explaining after the fact why the undo wasn’t clean;
  • Re-executing a command always bypasses the shell. A ps command is a space-joined string, and re-handing it to a shell treats spaces, quotes, and ; as syntax; take the real argv from the kernel, launch directly with Process, and let special characters be mere literals — that’s both correctness and security;
  • An operation that can change the outside world must tolerate dying mid-way. Persist “in progress” before acting, recover after a crash to “unconfirmed” rather than blindly retrying; a cross-process lock serialized by stable project identity guards against concurrent doubles;
  • Persistent-state failure fails closed too. An unreadable ledger keeps its raw bytes, not treated as empty; a single corrupt entry gets isolated, not junking the whole book; initiated ≠ succeeded, so don’t let the UI paint “attempted” as “restored.”

This closes the Pier 3.0 safety series. The four posts together are one philosophy unfolded across different layers: positioning and the safety philosophy, fail-closed grading, TOCTOU-resistant safe stopping, and this one’s safe restart and recovery — the core throughout is that one line, “prefer a miss over a wrong kill,” and “when uncertain, express the uncertainty honestly.”

Comments

  • Loading…

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