Skip to content

How Desktop Apps Detect and Kill Stale Processes on Startup

You close a desktop app, but its process is still sitting there in the task manager or Activity Monitor. Many people have run into this. This article looks at the design behind a common fix: detecting a leftover process from a previous run at startup, cleaning it up safely, and only then starting fresh.

Why a Process Can Fail to Exit

A Python desktop app built with a Flask backend and a browser as its display, packaged into a single executable with PyInstaller, often relies on a hard-exit call like os._exit(0) to shut down. The catch: calling this from a background daemon thread doesn’t always terminate the process in a frozen (packaged) build. From the user’s point of view, they clicked “quit,” but the process kept running behind the scenes.

Note: a daemon thread is one that gets forcibly terminated when the main thread exits. It’s commonly used for background work, but calling os._exit() from inside one doesn’t guarantee the whole process actually terminates.

A leftover process like this causes trouble the next time the app is launched — the port it was using is still occupied, or two instances end up running and conflicting with each other.

Separating “the PID is alive” from “the port is responding”

This app writes the running instance’s information to a port file (app_running.port) as port\nPID. On the next launch, it reads this file and checks whether the recorded PID is actually still alive.

def _is_pid_alive(pid: int) -> bool:
    """Check whether a PID is alive (macOS/Windows)"""
    if sys.platform == 'win32':
        try:
            import ctypes
            kernel32 = ctypes.windll.kernel32
            SYNCHRONIZE = 0x00100000
            handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid)
            if handle:
                kernel32.CloseHandle(handle)
                return True
            return False
        except Exception:
            return False
    else:
        try:
            os.kill(pid, 0)
            return True
        except (OSError, ProcessLookupError):
            return False

What’s worth noting here is that checking whether a process is alive works completely differently on Unix-like systems versus Windows. On Unix-like systems, os.kill(pid, 0) sends signal number 0 — which, per the POSIX spec, doesn’t actually deliver a signal at all. It’s a special case that only performs permission and existence checks. A nonexistent PID raises ProcessLookupError (a subclass of OSError). Windows has no equivalent to signal 0, so the check instead tries to obtain a process handle via OpenProcess() and treats success or failure as the liveness result.

But “the PID is alive” and “the process is working correctly” aren’t the same thing, and that distinction is the core of this design. A process can be alive while its main loop is hung or its listening socket has stopped responding — from the outside, that’s indistinguishable from an unresponsive zombie. So beyond checking whether the PID exists, the code also checks whether the recorded port actually accepts a connection.

def _kill_stale_process():
    port, pid = _read_port_file()
    if pid is None:
        return
    if not _is_pid_alive(pid):
        # Process already exited, only the port file is left behind — clean up only
        _cleanup_instance_files()
        return
    # Process is alive but the port doesn't respond — treat it as a zombie
    is_listening = False
    if port is not None:
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.settimeout(1.0)
                is_listening = s.connect_ex(('127.0.0.1', port)) == 0
        except Exception:
            pass
    if not is_listening:
        _force_kill_pid(pid)
        _cleanup_instance_files()
    # if is_listening=True (running normally), leave the port file alone

This function splits the port file’s state into three cases at startup:

  1. The PID is already dead: the process already exited, cleanly or otherwise. Only the leftover port file needs cleaning up.
  2. The PID is alive but the port doesn’t respond: the process is still around but isn’t doing its job — a zombie. This is the case that gets force-killed.
  3. The PID is alive and the port responds: a genuinely running instance. Nothing happens here, and the port file is left untouched.

Misclassifying case 3 as “an old process” and killing it would take down a healthy instance the user never asked to stop. Not stopping at a liveness check, and going one step further to confirm the process is actually serving requests, is a deliberate second layer meant to avoid exactly that kind of false positive.

Force-killing also differs by OS

Just like the liveness check, the mechanism for force-killing a process differs by platform.

def _force_kill_pid(pid: int):
    """Force-kill a PID (macOS/Windows)"""
    if sys.platform == 'win32':
        try:
            subprocess.run(
                ['taskkill', '/F', '/PID', str(pid)],
                stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=5,
                creationflags=subprocess.CREATE_NO_WINDOW
            )
        except Exception:
            pass
    else:
        try:
            os.kill(pid, signal.SIGKILL)
        except Exception:
            pass

On Unix-like systems, this sends SIGKILL. That signal can’t be caught or ignored by the target process — it’s a kernel-level termination, which is exactly what’s needed for a zombie process whose normal signal handlers may not even be functioning anymore. Windows has no direct equivalent, so the code shells out to taskkill /F instead.

macOS has its own reactivation quirk

Everything above is about cleaning up after a process that failed to exit properly. macOS adds a separate wrinkle on top of that: clicking an already-running app’s icon in the Dock or Finder doesn’t launch a new process at all — it just “activates” (brings to the front) the existing one. That’s standard macOS behavior across the board, but it’s not automatically convenient for an app built around a Flask backend plus a browser front end.

In a frozen build running on macOS, when this app detects an existing instance already running, it requests a graceful shutdown (waiting for any maintenance operation in progress to finish), sends SIGTERM, waits up to 15 seconds, and falls back to SIGKILL if the process is still alive — only then does it start up again as a new process. Without this, simply reactivating the existing instance wouldn’t reflect what the user actually wants when they double-click the app expecting a fresh launch.

A different problem: the browser tab closes, the server doesn’t

Separate from detecting and killing stale processes at startup, this app also handles a different kind of leftover-process problem: what happens when the browser tab is closed but the server process keeps running. The browser sends a request to /api/heartbeat every 30 seconds, and if the server hasn’t received one in 60 seconds — and no maintenance operation is in progress — it sends itself SIGKILL and exits.

This mechanism serves a different purpose and fires at a different time than the startup-time stale-process check. Heartbeat monitoring answers “how does a running process realize it’s been abandoned by the user,” while _kill_stale_process answers “how does a newly starting process clean up the wreckage of a previous one.” They can look like the same category of problem — process cleanup — but they differ in who’s doing the detecting (the running process itself, versus the next process about to start) and when it happens (continuous monitoring, versus a one-time check at launch).

Summary

Point Takeaway
Checking if a PID is alive Unix-like systems use os.kill(pid, 0); Windows uses OpenProcess() — the mechanism differs by OS
“Alive” vs. “actually working” Checking whether the port responds, not just whether the PID exists, avoids killing a healthy instance by mistake
Force-killing SIGKILL on Unix-like systems, taskkill /F on Windows
macOS reactivation Because Dock/Finder won’t launch a new process for a running app, the old one has to be explicitly terminated before starting fresh
vs. heartbeat monitoring Startup cleanup and abandonment detection solve different problems on different timelines

Something as seemingly simple as “terminate a process” turns out to involve several stacked decisions: platform-specific ways to check whether something is alive, a multi-step check to avoid killing a healthy process by accident, and handling for platform-specific quirks like macOS reactivation.