Skip to content

When a macOS desktop app refuses to restart — LaunchServices and the Flask-server pattern

WP Maintenance Manager ships as a desktop app with an unusual structure under the hood: a local Flask server with a browser-rendered UI. Double-clicking the app icon starts the server, opens the user’s default browser at http://127.0.0.1:<port>/, and the management UI lives in that tab.

The pattern fits well for tools that need rich admin UIs without the complexity of a full Electron build, and it lets us reuse standard web stack assets. Once the app reached real users on macOS, however, we got a recurring report: “I closed the browser tab, and now the app won’t restart.” The investigation led us not to a code bug, but to a behavior built into macOS itself ── the LaunchServices framework ── and to a fix that anyone shipping a “Flask + browser UI” desktop app on macOS will eventually need to write.

This post documents the cause, the heartbeat-based fix we ended up with, and a separate lockfile bug we caught along the way.


1. Context — how this app launches

The flow is:

1. User double-clicks the app icon 2. Internally, the Flask server boots and binds to a free local port 3. The default browser is launched, opening http://127.0.0.1:<port>/ 4. The browser tab serves as the UI; users manage WordPress sites, run updates, and view reports from there

Benefits of this pattern: HTML/CSS/JS for the UI, easy reuse of web ecosystem assets, lightweight to build and ship, and a path forward for accessing the same UI from other machines on the LAN if needed.

The downside is the next section’s topic: the definition of “the app is running” diverges from what users intuitively expect.


2. The bug — closing the tab kills the ability to restart

The exact reproduction reported by users:

1. Run a maintenance pass 2. Once it’s done, close the browser tab 3. Later, double-click the app icon again 4. Nothing happens ── no browser opens, no server responds 5. Restarting the Mac fixes it. But closing the tab again brings the same state back

100% reproducible. Re-reading the launch sequence in code shows nothing wrong. No errors in Console.app. The intuition is “click should launch the app”, but something is in the way and silently swallowing the click.


3. The cause — macOS LaunchServices behavior

The cause sits one level below the application layer: macOS LaunchServices.

Windows and macOS take different launch models

| OS | Behavior on icon double-click | |—|—| | Windows | Starts a new process every time | | macOS | If a process with the same identity is already running, does not start a new one — activates (foregrounds) the existing one instead |

This is why opening Mail or Safari twice doesn’t get you two separate instances. LaunchServices treats apps as singletons by default. For ordinary GUI apps, this is the correct behavior.

The mismatch with Flask + browser UI apps

For our pattern, this collides with user expectations:

User mental model:

  • Closed the tab → the app is finished

Actual state:

  • Closed the tab → exactly one browser tab disappeared
  • The Flask server process is still running in the background
  • Double-clicking the icon → LaunchServices says “yep, this app is already running” and tries to activate it
  • There is no visible window to activate, so nothing visible happens

To the user, the app appears broken. From the OS’s perspective, it’s behaving exactly as designed. The mismatch is between the application’s structure and the OS’s lifecycle policy, not a code bug.


4. The fix — a heartbeat mechanism

To bridge this gap, we adopted a heartbeat pattern. Picture a heart-rate monitor.

How it works

  • While the browser tab is open, JS in the page pings a /api/heartbeat endpoint every 30 seconds
  • The server records the timestamp of the most recent ping
  • A background loop checks: if the last ping was more than 3 minutes ago, terminate the server process

This produces the desired flow:

User behavior:    launch → use → close tab → ... idle ... → relaunch
Internal state:   server up → pings come in → pings stop → watchdog
                  self-terminates → process gone → next double-click starts
                  a fresh instance

Tuning notes

  • 30-second interval, 3-minute timeout (six missed pings) hit the right trade-off
  • Shorter intervals get false positives from browser sleep, mobile network drops, system suspend
  • Longer windows mean the user has to wait too long before they can relaunch
  • Maintenance runs need protection separately (next subsection)

Protecting long-running maintenance runs

WP Maintenance Manager often spends a long time in a maintenance pass ── dozens of sites, sequentially updated. If the user accidentally closes the tab during this, the server still needs to finish the work, write logs, and ship the report email.

So:

  • A separate flag tracks whether a long-running task is in progress
  • While that flag is set, heartbeat timeouts do not terminate the server
  • Once the task completes, normal heartbeat-based termination resumes

End result: closing the tab is safe at any moment ── maintenance runs to completion either way, and the server tidies itself up afterwards.


5. The bonus bug — lockfile cleanup that nukes itself

After the heartbeat fix shipped, we still saw a small number of “won’t restart” reports trickle in. Tracking them down led to an entirely separate bug.

Lockfile management was clobbering live processes

The app uses small lockfiles (a JSON record with PID and bound port) to keep track of which instance is running. The intent: prevent two instances of the app from racing on the same port and confusing each other.

There was a bug in the startup cleanup pass for these lockfiles. The intent was “delete stale lockfiles left behind by past crashes.” The actual implementation deleted all lockfiles it found, regardless of whether the PID inside was still alive.

Result:

  • Instance A starts → writes its lockfile
  • Instance B tries to start (or even just re-runs the cleanup pass) → deletes A’s lockfile
  • Instance A keeps running, but is now invisible to lock-aware checks
  • Double-launch detection silently fails, and state goes increasingly weird

The fix

Three small adjustments:

1. Before deleting a lockfile, check whether the PID it references is still live (os.kill(pid, 0) raising ProcessLookupError for a dead one) 2. Never delete the lockfile that belongs to my own PID 3. Only the records that pass both checks are considered “safely stale” and removed

Combined with heartbeat-based shutdown, this got us to the final spec users see today: about 60 seconds after closing the tab, the app can be relaunched.

Why 60 seconds? It’s two heartbeat cycles (30 s × 2). One cycle gives the watchdog a chance to confirm the tab is gone; a second cycle gives a generous safety margin against transient network blips.


6. Lessons we extracted

The hard part of this bug class is that the code looks correct ── because the bug isn’t in the code. It’s in the implicit contract between the app’s structure and the OS’s lifecycle rules.

For desktop apps shipping cross-platform:

  • Process-lifecycle behavior differs between Windows and macOS in a way that matters for any “local server + browser UI” pattern
  • Lifecycle tests need to be written per-OS, with explicit attention to “what happens if the user closes the visible UI but the underlying process keeps running”
  • Don’t trust startup cleanup logic that hasn’t proven it can distinguish a stale record from a live one

If you’re shipping a Flask (or similar local HTTP server) + browser UI desktop app on macOS, three checklist items deserve hardening before release:

1. Closing the tab eventually causes the process to exit (LaunchServices won’t block subsequent launches) 2. A long-running task isn’t aborted by heartbeat timeouts (maintenance runs survive accidental tab close) 3. Lockfile cleanup never deletes its own live record (no self-inflicted state corruption)


Summary

  • macOS LaunchServices treats apps as singletons; it activates the running instance instead of starting a new one
  • Flask + browser UI apps create a hidden running process when only a tab is closed, leaving LaunchServices with nothing visible to activate
  • A heartbeat mechanism (browser pings server, server self-terminates after silence) re-aligns “the user closed the tab” with “the app is no longer running”
  • Long-running tasks get a separate protection flag so they aren’t killed by silence during their own work
  • Audit your lockfile cleanup logic — make sure it can’t delete its own live record

WP Maintenance Manager is built around the assumption that operational reliability for the maintenance tool itself sets the ceiling on the maintenance you can deliver to clients. The macOS launch-cycle issue described here was one of the corners we shipped through. See the User Guide or grab the desktop app from the main site.