Build1 publisher2 min readPublished
A port file lets the next launch tell a hung instance from a healthy one
A Flask app packaged by PyInstaller kept running after users quit, because os._exit(0) fired from a daemon thread does not reliably kill a frozen build. The cleanup now happens at the next startup.
The Engineer · Build desk

What happened
- A dev.to write-up documents a Python desktop app with a Flask backend shown in a browser and packaged by PyInstaller into a single executable, whose process kept running after the user quit.
- The running instance records its own information in a file named app_running.port, written as the port on one line and the PID on the next.
- Startup then connects to the recorded port on 127.0.0.1 with a one-second timeout, and force-kills only when the PID is alive and the port fails to respond.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- decision Moving the kill to startup means an unreliable shutdown path can stay in the shipped build, and the next launch does the cleanup the quit button failed to do.
- constraint The second layer only exists for an app that publishes a port it answers on; a packaged app whose background worker has no listening socket gives startup nothing to probe, so it is back to trusting the PID.
- cost Portability is paid for in duplicate code, since each check needs one implementation per platform; verify the fix on a Mac and the Windows branch is still untested.
The post attributes the leftover process to os._exit(0) called from a background daemon thread, and it does not explain why that call fails in a frozen build [22]. A call whose entire purpose is to end the process immediately sometimes does not [3]. Daemon threads are the ones forcibly terminated when the main thread exits [4], so the quit path asks a disposable thread to take the whole process down with it. The post documents the workaround: detect the leftover process at the next startup, clean it up, and only then start fresh [20].
The liveness check is the careful part of the design. On Unix-like systems it calls os.kill(pid, 0), and POSIX treats signal 0 as a special case that delivers nothing and performs only the permission and existence checks [8]. A PID nobody owns raises ProcessLookupError, a subclass of OSError [9]. Windows has no equivalent, so the code asks kernel32 for a handle via OpenProcess with SYNCHRONIZE rights, 0x00100000, closes it again, and treats the success of the open as the answer [10].
A live PID can belong to a hung app. The post's argument is that a process can be alive while its main loop is hung or its listening socket has stopped responding. From outside, that is indistinguishable from an unresponsive zombie [11]. So startup opens a TCP connection to 127.0.0.1 on the recorded port using connect_ex with a one-second timeout, and reads a return value of 0 as listening [12]. Of the three states that come out, exactly one calls the force-kill: a live PID whose port stays silent [13][17]. An instance that answers is left running with its port file untouched [13], because killing it would take down an app the user never asked to stop [14].
Identity in this design is a number in a file. Both checks ask whether something holds the recorded PID, not whether that something is the app [19]. When the port does not answer, the Windows path runs taskkill /F /PID against that number with its output discarded [15]. Whatever holds the number is what gets killed [19].
settimeout(1.0) caps the probe at one second, and one second is the ceiling on what this handshake adds to a cold start [18]. Both helper functions carry macOS and Windows in their docstrings [23].
What to watch
- Whether a PyInstaller issue report pins down why os._exit(0) from a daemon thread leaves a frozen build running.
- Whether the port file gains an identity token, such as process start time, so a reused PID cannot be force-killed by mistake.
- Whether the Unix branch of both helpers is tested on Linux, since the docstrings name only macOS and Windows.