fix(windows): supervise the status tray, and stop the host on exit #451

Merged
enricobuehler merged 1 commits from fix/windows-tray-supervisor into main 2026-08-30 20:42:09 +00:00
Owner

Reported again on 2026-08-30: "when the windows host gets updated the tray does not restart".

The fix that was already there, and why it could never have held

d9912aa7 (2026-07-30) added a host-side relaunch: the update's intent record carried
tray_was_running, and boot reconciliation called relaunch_tray(). That code is in main and
reads correctly. It still could not put a tray back and keep it.

windows/service.rs:386 puts the service worker in a job object created
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK. On Windows a child joins
its parent's job by default
BREAKAWAY_OK only permits escape, it never performs it.
spawn_in_active_session passed only CREATE_UNICODE_ENVIRONMENT. So everything that call
launched into the console session — the relaunched tray, the user's game, a hook — sat inside a
kill-on-close job and was reaped the moment the service stopped or restarted.

That flatly contradicts the function's own doc comment (interactive.rs:58): "Fire-and-forget: the
launched game/launcher outlives this call."
The sibling update/windows.rs:25 documents this
exact hazard and passes CREATE_BREAKAWAY_FROM_JOB for the installer spawn. The tray path needed
the same flag and never had it.

The second, independent weakness: the July mechanism only ever covered console-initiated
updates, and only when the previous binary already knew to record the flag. A winget upgrade, a
hand-run setup, or a plain tray crash all still ended with a dead icon until the next sign-in.

What this changes

  1. spawn_in_active_session breaks away from the job. On refusal (a job without BREAKAWAY_OK
    rejects CreateProcessAsUserW with ACCESS_DENIED) it retries without the flag — launching
    inside the job beats not launching at all.
  2. windows/tray.rs::supervise() — a thread the host runs for its whole lifetime, started from
    mgmt::run right after reconcile_at_boot. First check immediate (that is the post-update
    restore), then every 30 s needing two consecutive misses. Gated on the HKLM Run value
    PunktfunkTray, which is the only honest "this box wants an icon" signal — punktfunk-tray.exe
    is installed unconditionally, so its presence on disk means nothing.
  3. tray_was_running and relaunch_tray deleted. The supervisor strictly subsumes them, fires
    at boot too, and depends on nothing the previous binary wrote. #[serde(default)] on the
    removed field means older intent records still parse (unit-tested).
  4. "Exit tray" stops the host. Only IDM_EXIT does this — WM_CLOSE (the uninstaller's
    --quit) and WM_ENDSESSION (sign-out, shutdown) still leave a headless host running, which is
    the whole point of a headless host. The entry relabels to "Stop host and exit tray" and carries
    the UAC shield; elevate_service now reports whether the elevated child launched, so a
    declined UAC prompt cancels the exit rather than leaving an icon-less running host.
  5. Uninstaller order (punktfunk-host.iss [UninstallRun]): service uninstall now runs
    before the tray --quit/taskkill. Otherwise the still-running supervisor can put a tray back
    and re-lock punktfunk-tray.exe just as file deletion starts. The installer was already correct
    CurStepChanged(ssInstall) does StopHostServiceAndWait before StopTrays.

The two-miss grace tick in (2) is what stops the supervisor fighting (4): exiting the tray stops
this service, and the supervisor dies with it a few seconds later.

Verification

scripts/xcheck.sh covers neither punktfunk-host nor punktfunk-tray, and the package job
that carries "Clippy (host + tray, Windows)" does not run on a pull request. So this was checked by
hand on the windows-amd64 runner, against this exact tree:

Check Result
cargo clippy -p punktfunk-host -p punktfunk-tray --all-targets -- -D warnings (Windows) exit 0
cargo fmt -p punktfunk-host -p punktfunk-tray -- --check exit 0
cargo test --bin punktfunk-host update:: 14 passed
cargo test -p punktfunk-tray 6 passed
ISCC.exe compiling punktfunk-host.iss (stub [Files] payloads) exit 0
cargo check -p punktfunk-host --bins on Linux (punktfunk-rust-ci) exit 0

Both gates proven non-vacuous, not just green: a planted let _x: u32 = "s"; in
windows/tray.rs makes the Windows command exit 101 with E0308, and a planted error in
update/linux.rs fails the Linux one. Restored and re-confirmed clean afterwards.

⚠ Not verified, and worth saying plainly

Nothing here proves the tray actually reappears on glass. That needs a real console-initiated
update on a box with a signed-in user. This is the same gap the July fix carried, which is exactly
why it shipped broken — please exercise it before this rides in a release.

Deliberate consequence: games survive a host restart

The breakaway flag in (1) is not tray-specific, and it should not be. Checked before changing it:

  • The host terminates games explicitly by pid, never by job teardown — gamelease.rs:1155
    (windows_term_ladder) → game_term::request_close → 10 s grace → game_term::kill. It holds no
    process handle (interactive.rs:180 closes both immediately).
  • Nothing documents games as dying with the service. The one component that genuinely must is the
    web console, and it gets its own job without BREAKAWAY_OK for precisely that reason
    (service.rs:762: "nothing it runs may outlive the service").
  • hooks.rs:794 keeps no handle and never kills its child either.

So nothing leaks. The real cost is a capability gap: a game that survives a service restart has no
lease and no record, so procscan will not re-adopt it (launchreg.rs:12) — its exit will not end
a session, and "End" cannot reach it. The same safety rule that prevents re-adoption is what stops
the host mis-killing a player-started copy, so this is a gap rather than a hazard. A service stop
during the 10 s close grace now also leaves the game running after its WM_CLOSE, which is
arguably the better outcome than the job's unannounced TerminateProcess on unsaved work.

Residue, deliberate

  • punktfunk-host tray stop is undone within a minute while the host runs. It is a diagnostic;
    turning the icon off for good means clearing the HKLM Run value.
  • start() checks is_running() in any session, so a tray alive in an RDP session suppresses
    the console one. Pre-existing, unchanged.
  • Linux is untouched. The Linux packages never force-kill a running tray, and the desktop autostart
    entry owns bringing it up.
Reported again on 2026-08-30: *"when the windows host gets updated the tray does not restart"*. ## The fix that was already there, and why it could never have held `d9912aa7` (2026-07-30) added a host-side relaunch: the update's intent record carried `tray_was_running`, and boot reconciliation called `relaunch_tray()`. That code is in `main` and reads correctly. It still could not put a tray back and keep it. `windows/service.rs:386` puts the service worker in a job object created `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK`. On Windows **a child joins its parent's job by default** — `BREAKAWAY_OK` only *permits* escape, it never performs it. `spawn_in_active_session` passed only `CREATE_UNICODE_ENVIRONMENT`. So everything that call launched into the console session — the relaunched tray, the user's game, a hook — sat inside a kill-on-close job and was reaped the moment the service stopped or restarted. That flatly contradicts the function's own doc comment (`interactive.rs:58`): *"Fire-and-forget: the launched game/launcher outlives this call."* The sibling `update/windows.rs:25` documents this exact hazard and passes `CREATE_BREAKAWAY_FROM_JOB` for the installer spawn. The tray path needed the same flag and never had it. The second, independent weakness: the July mechanism only ever covered **console-initiated** updates, and only when the *previous* binary already knew to record the flag. A winget upgrade, a hand-run setup, or a plain tray crash all still ended with a dead icon until the next sign-in. ## What this changes 1. **`spawn_in_active_session` breaks away from the job.** On refusal (a job without `BREAKAWAY_OK` rejects `CreateProcessAsUserW` with `ACCESS_DENIED`) it retries without the flag — launching inside the job beats not launching at all. 2. **`windows/tray.rs::supervise()`** — a thread the host runs for its whole lifetime, started from `mgmt::run` right after `reconcile_at_boot`. First check immediate (that is the post-update restore), then every 30 s needing **two consecutive misses**. Gated on the HKLM `Run` value `PunktfunkTray`, which is the only honest "this box wants an icon" signal — `punktfunk-tray.exe` is installed unconditionally, so its presence on disk means nothing. 3. **`tray_was_running` and `relaunch_tray` deleted.** The supervisor strictly subsumes them, fires at boot too, and depends on nothing the previous binary wrote. `#[serde(default)]` on the removed field means older intent records still parse (unit-tested). 4. **"Exit tray" stops the host.** Only `IDM_EXIT` does this — `WM_CLOSE` (the uninstaller's `--quit`) and `WM_ENDSESSION` (sign-out, shutdown) still leave a headless host running, which is the whole point of a headless host. The entry relabels to "Stop host and exit tray" and carries the UAC shield; `elevate_service` now reports whether the elevated child launched, so a **declined UAC prompt cancels the exit** rather than leaving an icon-less running host. 5. **Uninstaller order** (`punktfunk-host.iss` `[UninstallRun]`): `service uninstall` now runs *before* the tray `--quit`/`taskkill`. Otherwise the still-running supervisor can put a tray back and re-lock `punktfunk-tray.exe` just as file deletion starts. The installer was already correct — `CurStepChanged(ssInstall)` does `StopHostServiceAndWait` before `StopTrays`. The two-miss grace tick in (2) is what stops the supervisor fighting (4): exiting the tray stops this service, and the supervisor dies with it a few seconds later. ## Verification `scripts/xcheck.sh` covers **neither** `punktfunk-host` nor `punktfunk-tray`, and the `package` job that carries "Clippy (host + tray, Windows)" does not run on a pull request. So this was checked by hand on the `windows-amd64` runner, against this exact tree: | Check | Result | |---|---| | `cargo clippy -p punktfunk-host -p punktfunk-tray --all-targets -- -D warnings` (Windows) | exit 0 | | `cargo fmt -p punktfunk-host -p punktfunk-tray -- --check` | exit 0 | | `cargo test --bin punktfunk-host update::` | 14 passed | | `cargo test -p punktfunk-tray` | 6 passed | | `ISCC.exe` compiling `punktfunk-host.iss` (stub `[Files]` payloads) | exit 0 | | `cargo check -p punktfunk-host --bins` on Linux (`punktfunk-rust-ci`) | exit 0 | **Both gates proven non-vacuous**, not just green: a planted `let _x: u32 = "s";` in `windows/tray.rs` makes the Windows command exit **101** with `E0308`, and a planted error in `update/linux.rs` fails the Linux one. Restored and re-confirmed clean afterwards. ## ⚠ Not verified, and worth saying plainly **Nothing here proves the tray actually reappears on glass.** That needs a real console-initiated update on a box with a signed-in user. This is the same gap the July fix carried, which is exactly why it shipped broken — please exercise it before this rides in a release. ## Deliberate consequence: games survive a host restart The breakaway flag in (1) is not tray-specific, and it should not be. Checked before changing it: - The host **terminates games explicitly by pid**, never by job teardown — `gamelease.rs:1155` (`windows_term_ladder`) → `game_term::request_close` → 10 s grace → `game_term::kill`. It holds no process handle (`interactive.rs:180` closes both immediately). - Nothing documents games as dying with the service. The one component that genuinely must is the **web console**, and it gets its own job *without* `BREAKAWAY_OK` for precisely that reason (`service.rs:762`: "nothing it runs may outlive the service"). - `hooks.rs:794` keeps no handle and never kills its child either. So nothing leaks. The real cost is a capability gap: a game that survives a service restart has no lease and no record, so `procscan` will not re-adopt it (`launchreg.rs:12`) — its exit will not end a session, and "End" cannot reach it. The same safety rule that prevents re-adoption is what stops the host mis-killing a player-started copy, so this is a gap rather than a hazard. A service stop during the 10 s close grace now also leaves the game running after its `WM_CLOSE`, which is arguably the better outcome than the job's unannounced `TerminateProcess` on unsaved work. ## Residue, deliberate - `punktfunk-host tray stop` is undone within a minute while the host runs. It is a diagnostic; turning the icon off for good means clearing the HKLM `Run` value. - `start()` checks `is_running()` in **any** session, so a tray alive in an RDP session suppresses the console one. Pre-existing, unchanged. - Linux is untouched. The Linux packages never force-kill a running tray, and the desktop autostart entry owns bringing it up.
enricobuehler added 1 commit 2026-08-30 16:08:53 +00:00
fix(windows): supervise the status tray, and stop the host on exit
ci / bun-nix (pull_request) Successful in 22s
ci / docs-drift (pull_request) Failing after 37s
ci / web (pull_request) Successful in 58s
ci / docs-site (pull_request) Successful in 57s
ci / rust-arm64 (pull_request) Successful in 1m55s
ci / rust (pull_request) Failing after 6m32s
android / android (pull_request) Successful in 7m39s
0329afcb58
The tray died on every upgrade and stayed dead until the next sign-in. The
update-specific remedy only covered console-initiated updates, and only when
the previous binary already recorded the intent — winget, a hand-run setup and
a plain crash all still ended the same way.

Worse, the relaunch it did manage joined the service worker's kill-on-close
job object, because spawn_in_active_session never asked to break away. Every
process that call launches — the tray, the user's game, a hook — was therefore
reaped when the service stopped, contradicting its own documented contract.

The host now supervises the tray for its whole lifetime, gated on the HKLM Run
value the trayicon task writes. That covers every way a tray can die and needs
nothing from the version that ran before, so the intent record's
tray_was_running flag goes.

The icon's lifetime tracks the host's in both directions: the menu's exit
entry stops the host, and says so. Only that entry does — a sign-out and the
uninstaller's --quit still leave a headless host running. The uninstaller now
removes the service before the tray, so the supervisor cannot put one back.
enricobuehler merged commit 2532e8a53c into main 2026-08-30 20:42:09 +00:00
enricobuehler deleted branch fix/windows-tray-supervisor 2026-08-30 20:42:23 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#451