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
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.
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.
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).
"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.
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 neitherpunktfunk-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:
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 withoutBREAKAWAY_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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 carriedtray_was_running, and boot reconciliation calledrelaunch_tray(). That code is inmainandreads correctly. It still could not put a tray back and keep it.
windows/service.rs:386puts the service worker in a job object createdJOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK. On Windows a child joinsits parent's job by default —
BREAKAWAY_OKonly permits escape, it never performs it.spawn_in_active_sessionpassed onlyCREATE_UNICODE_ENVIRONMENT. So everything that calllaunched 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: thelaunched game/launcher outlives this call." The sibling
update/windows.rs:25documents thisexact hazard and passes
CREATE_BREAKAWAY_FROM_JOBfor the installer spawn. The tray path neededthe 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
spawn_in_active_sessionbreaks away from the job. On refusal (a job withoutBREAKAWAY_OKrejects
CreateProcessAsUserWwithACCESS_DENIED) it retries without the flag — launchinginside the job beats not launching at all.
windows/tray.rs::supervise()— a thread the host runs for its whole lifetime, started frommgmt::runright afterreconcile_at_boot. First check immediate (that is the post-updaterestore), then every 30 s needing two consecutive misses. Gated on the HKLM
RunvaluePunktfunkTray, which is the only honest "this box wants an icon" signal —punktfunk-tray.exeis installed unconditionally, so its presence on disk means nothing.
tray_was_runningandrelaunch_traydeleted. The supervisor strictly subsumes them, firesat boot too, and depends on nothing the previous binary wrote.
#[serde(default)]on theremoved field means older intent records still parse (unit-tested).
IDM_EXITdoes this —WM_CLOSE(the uninstaller's--quit) andWM_ENDSESSION(sign-out, shutdown) still leave a headless host running, which isthe whole point of a headless host. The entry relabels to "Stop host and exit tray" and carries
the UAC shield;
elevate_servicenow reports whether the elevated child launched, so adeclined UAC prompt cancels the exit rather than leaving an icon-less running host.
punktfunk-host.iss[UninstallRun]):service uninstallnow runsbefore the tray
--quit/taskkill. Otherwise the still-running supervisor can put a tray backand re-lock
punktfunk-tray.exejust as file deletion starts. The installer was already correct—
CurStepChanged(ssInstall)doesStopHostServiceAndWaitbeforeStopTrays.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.shcovers neitherpunktfunk-hostnorpunktfunk-tray, and thepackagejobthat carries "Clippy (host + tray, Windows)" does not run on a pull request. So this was checked by
hand on the
windows-amd64runner, against this exact tree:cargo clippy -p punktfunk-host -p punktfunk-tray --all-targets -- -D warnings(Windows)cargo fmt -p punktfunk-host -p punktfunk-tray -- --checkcargo test --bin punktfunk-host update::cargo test -p punktfunk-trayISCC.execompilingpunktfunk-host.iss(stub[Files]payloads)cargo check -p punktfunk-host --binson Linux (punktfunk-rust-ci)Both gates proven non-vacuous, not just green: a planted
let _x: u32 = "s";inwindows/tray.rsmakes the Windows command exit 101 withE0308, and a planted error inupdate/linux.rsfails 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:
gamelease.rs:1155(
windows_term_ladder) →game_term::request_close→ 10 s grace →game_term::kill. It holds noprocess handle (
interactive.rs:180closes both immediately).web console, and it gets its own job without
BREAKAWAY_OKfor precisely that reason(
service.rs:762: "nothing it runs may outlive the service").hooks.rs:794keeps 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
procscanwill not re-adopt it (launchreg.rs:12) — its exit will not enda 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 isarguably the better outcome than the job's unannounced
TerminateProcesson unsaved work.Residue, deliberate
punktfunk-host tray stopis undone within a minute while the host runs. It is a diagnostic;turning the icon off for good means clearing the HKLM
Runvalue.start()checksis_running()in any session, so a tray alive in an RDP session suppressesthe console one. Pre-existing, unchanged.
entry owns bringing it up.