The shell recognises its own backend by token, not by PID - #460
Merged
Conversation
Every Windows portable launch since 0.14.0 has failed with "Another program is already using port 8000", naming StemDeck's own healthy backend as the intruder, after a full ninety second wait. #424 taught the shell to check *which* process answers /api/health, so a second StemDeck could no longer adopt the first one's backend and, with it, the first one's library. It established that identity by comparing the PID in the health payload against `child.id()`, which assumes the process that binds the port is the process we spawned. On the Windows portable build it is not. `python/Scripts/python.exe` is a venv launcher pointing at `python/base/python.exe`, and Windows has no exec, so the launcher starts the real interpreter as a child of its own. The process that binds the port is a grandchild, and its PID can never equal `child.id()`. Measured on a local CPU-only package: StemDeck.exe 6868 python.exe 6788 python\Scripts\python.exe <- child.id() python.exe 11636 python\base\python.exe <- binds :8000 So the comparison could not succeed, the poll loop ran to its deadline, and the foreign PID it had been recording all along became the error message. Linux and macOS were unaffected: python-build-standalone puts a real binary at python/bin/python with no launcher in front of it, which is why this only ever showed up on Windows. Identity now travels in the environment, which survives any number of re-execs: the shell generates a per-launch token, passes it as STEMDECK_INSTANCE_TOKEN, and the backend echoes it from /api/health. A backend that reports no token predates this shell and falls back to the PID comparison it was built for, so a half-updated install still starts. What #424 protects against is unchanged, and slightly stronger. A second StemDeck generates its own token, so the first instance's backend is refused on a mismatch rather than on a PID coincidence. The token only has to be unique per launch, not unguessable. It answers "is this the process I just started", and anything on loopback that wanted to lie could read the token out of the health response anyway. Deriving it keeps an RNG dependency out of a crate that has no other use for one. Verified by building the CPU-only portable package and running it: the app reached its window, and the health payload carried the token from a PID that was never the child.
Collaborator
Author
|
Reproduced the failure directly, rather than only demonstrating the fix. Built the pre-fix shell ( Pre-fix binary. The backend came up and stayed up: Ninety seconds later the shell had killed it and the window read "Another program is already using port 8000", sitting directly above a log tail of successful Fixed binary, same stage, immediately after. Healthy in about fifteen seconds and the app loaded its normal UI: Same Python, same backend code, same data directory. Only the shell differed. |
This was referenced Aug 26, 2026
a_free_port_is_granted_as_asked failed in CI asserting 62251 against 62250. The adjacent numbers are the tell: the test binds an ephemeral port to learn a free number, drops it, and asks reserve_port to claim that exact number back. Cargo runs this binary's tests in parallel and several of them stand up throwaway listeners, so a concurrent bind(0) can be handed the number in the gap. claim_port then fails, reserve_port falls back to free_port as designed, and the next port up comes back. The test was racing itself. reserve_port was right in the failing run. Scan a fixed range below the ephemeral range instead. Nothing calling bind(0) can be given a port outside it, which closes the window rather than narrowing it. It is also the honest shape of the thing under test: reserve_port is given a configured port, 8000 by default, never one the OS just handed out. Found while adding a third ephemeral listener in #457's tests, which is what took this from theoretical to observed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #457
Closes #461
The problem
Every Windows portable launch since 0.14.0 fails with:
There is no other program. That process is StemDeck's own backend, and it is healthy. The log tail in #457 shows
GET /api/health 200 OKthroughout the ninety seconds the shell spent waiting.Three reporters, all Windows, on both the CPU and NVIDIA packages.
Why
#424 taught the shell to check which process answers
/api/health, so a second StemDeck could no longer adopt the first one's backend and, with it, the first one's data directory and library. It established that identity by comparing the PID in the health payload againstchild.id().That assumes the process which binds the port is the process we spawned. On the Windows portable build it is not.
python/Scripts/python.exeis a venv launcher pointing atpython/base/python.exe(make-portable.ps1setshometopython/base), and Windows has noexec, so the launcher starts the real interpreter as a child of its own.Measured on a locally built CPU-only package:
So the comparison could never succeed. The poll loop ran to its deadline, and the foreign PID it had been recording all along became the error message.
Linux and macOS are unaffected. python-build-standalone puts a real binary at
python/bin/pythonwith no launcher in front of it, so the PIDs match there. That is why this only ever showed up on Windows.The change
Identity travels in the environment, which survives any number of re-execs. The shell generates a per-launch token, passes it as
STEMDECK_INSTANCE_TOKEN, and the backend echoes it back from/api/health.A backend that reports no token predates this shell and falls back to the PID comparison it was built for, so a half-updated install still starts. Every non-desktop distribution (Docker, Unraid, a source checkout) reports an empty token, which is read as no token rather than as a token they all share.
What #424 protects against is unchanged, and slightly stronger: a second StemDeck generates its own token, so the first instance's backend is refused on a mismatch rather than on a PID coincidence.
The token only has to be unique per launch, not unguessable. It answers "is this the process I just started", and anything on loopback that wanted to lie could read the token out of the health response anyway. Deriving it from the clock, the process and a counter keeps an RNG dependency out of a crate that has no other use for one.
Verified
Built the CPU-only Windows portable package locally and ran it.
34564, interpreter PID12116,STEMDECK_INSTANCE_TOKENintact in the second.Responding: True).{"pid": 11636, "instance": "2d9c14a69914eb..."}from a PID that was neverchild.id().Rust: 55 tests pass, clippy
-D warningsclean,cargo fmt --checkclean. Three tests cover this directly: the #424 stranger is still refused, a grandchild PID with our token is accepted, and a tokenless backend still starts.Python:
ruff checkandruff format --checkclean. Full suite is 776 passed against 774 on the unmodified tree, with the same 14 pre-existing local-environment failures (beatgrid, worker watchdog) on both.Release notes
No dependency change, so
uv.lockand theruntimeIdare untouched and existing desktop installs take this through the in-app updater. That matters here: the users this fixes are the ones who cannot get the app to start, and a fix they have to reinstall to receive is a fix many of them will never see.