Skip to content

The shell recognises its own backend by token, not by PID - #460

Merged
thcp merged 2 commits into
mainfrom
fix/backend-identity-token
Aug 26, 2026
Merged

The shell recognises its own backend by token, not by PID#460
thcp merged 2 commits into
mainfrom
fix/backend-identity-token

Conversation

@thcp

@thcp thcp commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Closes #457
Closes #461

The problem

Every Windows portable launch since 0.14.0 fails with:

backend did not become healthy within 90 seconds.
Another program is already using port 8000 (process 16452).

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 OK throughout 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 against child.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.exe is a venv launcher pointing at python/base/python.exe (make-portable.ps1 sets home to python/base), and Windows has no exec, so the launcher starts the real interpreter as a child of its own.

Measured on a locally built CPU-only package:

StemDeck.exe  6868
python.exe    6788   python\Scripts\python.exe   <- child.id()
python.exe   11636   python\base\python.exe      <- binds :8000, reports os.getpid()

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/python with 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.

  • The venv launcher re-exec was confirmed directly against the packaged runtime: launcher PID 34564, interpreter PID 12116, STEMDECK_INSTANCE_TOKEN intact in the second.
  • The app completed setup, started its backend and reached its window (Responding: True).
  • Health answered {"pid": 11636, "instance": "2d9c14a69914eb..."} from a PID that was never child.id().

Rust: 55 tests pass, clippy -D warnings clean, cargo fmt --check clean. 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 check and ruff format --check clean. 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.lock and the runtimeId are 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.

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.
@thcp

thcp commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced the failure directly, rather than only demonstrating the fix.

Built the pre-fix shell (main's main.rs) and dropped it onto the same already-set-up CPU-only package, so the only variable between runs was the executable.

Pre-fix binary. The backend came up and stayed up:

python.exe  36476   Scripts\python.exe   <- child.id()
python.exe  31732   base\python.exe      <- binds :8000
health: {"status":"ok","pid":31732,"instance":""}

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 GET /api/health requests. That is the screenshot in #457, reproduced.

Fixed binary, same stage, immediately after. Healthy in about fifteen seconds and the app loaded its normal UI:

python.exe  34792   Scripts\python.exe   <- child.id()
python.exe  37544   base\python.exe      <- binds :8000
health: {"status":"ok","pid":37544,"instance":"08a25e2e5ed32623e842c812bd97ac72"}

Same Python, same backend code, same data directory. Only the shell differed.

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.
@thcp
thcp merged commit f059ad1 into main Aug 26, 2026
12 checks passed
@thcp
thcp deleted the fix/backend-identity-token branch August 26, 2026 09:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The port reservation test can fail against a port it just released [Bug]: Another program is using port 8000

1 participant