Writing

The Single-Instance Race Nobody Tests For

The Single-Instance Race Nobody Tests For - abstract illustration

An app that binds a local server port has to guarantee only one instance of itself is ever running, or the second launch either crashes on the port bind or, worse, silently wins the race and runs alongside the first, both of them serving requests. The obvious way to guarantee that is checking whether another instance is already running before you start. The obvious way is also the one with a race condition built into it, and the race is exactly the kind you won't find by launching the app and checking if it worked.

The check that looks sufficient

The straightforward approach on macOS is NSRunningApplication.runningApplications(withBundleIdentifier:): ask the system for every running process with your bundle identifier, and if the list has anything in it already, this new launch should back off. It reads cleanly, it's a couple of lines, and if you test it by launching the app once, then launching it again a few seconds later, it works exactly as expected. The second launch sees the first in the list and quits.

The gap is in what "a few seconds later" hides. Between the moment the second launch calls runningApplications and the moment the first launch has actually registered itself as running, there is a real window, small, but not zero. If a second launch's check executes inside that window, before the first process has finished the sequence that makes it show up in the running-applications list, the check comes back empty. Nothing is running yet, as far as this call can tell. Both processes proceed. Both try to bind the same port, issue the same "first run" owner key, write to the same config file. This is a textbook time-of-check to time-of-use race: the state you checked can change between checking it and acting on it, and the two operations aren't atomic together.

Why you won't catch it by hand

The reason this kind of bug survives manual testing specifically is that the failure only happens inside a window measured in milliseconds, and launching an app by hand, double-clicking an icon twice, or running a command twice from a terminal, doesn't reliably land two launches inside that window. Most of the time there's enough human-scale delay between the two launches that the first one has long since finished registering itself before the second one checks. The bug is real and exploitable, but it's rare enough under normal manual use that you can test the feature, watch it behave correctly every time you try, and ship it with a race condition still live inside it.

It shows up for real when something launches multiple instances close together on its own: a login-item launch racing a user double-click, a crash-relaunch racing the original process's own slow shutdown, a script that starts the app twice in a loop for automated testing. None of those are exotic. They're exactly the conditions a single-instance guard exists to protect against, and they're exactly the conditions manual testing doesn't reproduce by default.

Closing it with a lock instead of a list

The fix is to stop asking the OS what's running and instead take an OS-level, atomic lock. I used an advisory flock on a small lock file in the app's Application Support directory, acquired as the very first thing that happens in applicationDidFinishLaunching, before the engine starts, before the MCP server binds its port, before anything else runs. flock is atomic at the kernel level: two processes racing to acquire the same lock cannot both succeed, full stop, no window where the answer is ambiguous. The process that gets the lock proceeds. The one that doesn't activates the existing instance's window and terminates itself immediately, having started nothing.

The other property that made this the right primitive, not just a working one: flock self-releases if the holding process crashes or gets killed. A lock file approach that requires explicit cleanup, delete the file on clean exit, leaves you exposed to a stale lock after a crash, where the file still exists and a legitimate relaunch can't tell a genuinely running instance from a dead one that forgot to clean up after itself. An advisory kernel lock doesn't have that failure mode. The lock is tied to the process holding it, not to a file's mere existence, so a crashed process releases it automatically and a real relaunch acquires it cleanly.

Proving it, not just believing it

Fixing a race condition without verifying the fix under the actual race is the same mistake as shipping it in the first place, just with more confidence attached. I verified this one by launching the app three times in quick succession, back to back, deliberately trying to land multiple launches inside the window the old check would have missed. The log told the whole story: exactly one "first-run owner key issued" line, exactly one "MCP server listening" line, one surviving process. No double bind, no double key issuance, no ambiguity.

That's the bar for this class of bug, and it's a different bar than "the feature works when I use it normally." A single-instance guard exists specifically for the case where normal use doesn't apply, two launches close enough together that a naive check can't tell them apart. Testing it under exactly that condition, three launches in a row, on purpose, is the only way to know the guard does its job instead of just looking like it does.

Building something like this?

This is the kind of work I do for clients. Tell me what you're building and I'll give you a straight read on the approach.

Book a 30-minute call