fix(windows/update): an update puts the status tray back instead of killing it for good

Every update started from the web console killed the tray permanently. Three
pieces had to line up, and they did: the installer's StopTrays force-kills every
punktfunk-tray.exe (it is one of the files being replaced), its relaunch is a
[Run] entry flagged skipifsilent, and the in-console updater spawns the installer
with /VERYSILENT. So the tray died on every such update and waited for the next
sign-in, because the Run value is a logon trigger. Confirmed on a box whose tray
was absent while its Run key, its exe and a session signed in hours before the
update were all present.

The installer cannot fix this itself. Spawned from the SYSTEM host service, its
`runasoriginaluser` resolves to SYSTEM — so relaunching there would place a
SYSTEM-owned tray in the user's session, where it would hold the per-session
Local\PunktfunkTray mutex and block the real tray at the next sign-in. Strictly
worse than the bug.

The host does it instead, because only the host has the right token. IntentRecord
gains tray_was_running, captured before the installer is spawned (reusing the
conflicting-host scan's process snapshot rather than opening a second one), and
boot reconciliation reads it off the intent before reconcile consumes it and
relaunches through tray::start(). Restored on both terminal outcomes — a
rolled-back install killed the tray just as dead as a successful one — but never
while an apply is still in flight, where the installer may only kill it again.

The field is serde(default), so an intent written by an older host reads as false
and behaves exactly as before; covered by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 20:42:50 +02:00
co-authored by Claude Opus 5
parent c77823d800
commit d9912aa795
4 changed files with 67 additions and 5 deletions
+12 -5
View File
@@ -584,11 +584,12 @@ enum PostApply {
/// apply. Called once from `mgmt::run` before the API serves.
pub(crate) fn reconcile_at_boot() {
let path = jobs::intent_path();
match jobs::reconcile(
jobs::read_intent(&path),
env!("PUNKTFUNK_VERSION"),
now_unix(),
) {
let intent = jobs::read_intent(&path);
// Read off the intent BEFORE `reconcile` consumes it. Restored on both terminal outcomes: a
// rolled-back or aborted install killed the tray just as thoroughly as a successful one. NOT on
// StillApplying — the installer may still be running and would only kill it again.
let restore_tray = intent.as_ref().is_some_and(|i| i.tray_was_running);
match jobs::reconcile(intent, env!("PUNKTFUNK_VERSION"), now_unix()) {
jobs::Reconciled::None | jobs::Reconciled::StillApplying => {}
jobs::Reconciled::Success(record) => {
tracing::info!(from = %record.from, to = %record.to, "host update applied");
@@ -610,6 +611,12 @@ pub(crate) fn reconcile_at_boot() {
let _ = std::fs::remove_file(&path);
}
}
#[cfg(target_os = "windows")]
if restore_tray {
windows::relaunch_tray();
}
#[cfg(not(target_os = "windows"))]
let _ = restore_tray; // the Linux packages never kill a running tray
}
/// What status hands to the API layer.
+29
View File
@@ -42,6 +42,23 @@ pub(crate) struct IntentRecord {
/// intent with this flag still present at boot ⟹ the rebuild succeeded.
#[serde(default)]
pub source_build: bool,
/// A per-user status tray was running when we spawned the installer, so boot reconciliation
/// should put it back.
///
/// The installer's `StopTrays` force-kills every session's `punktfunk-tray.exe` (it is one of
/// the files being replaced), and its `[Run]` relaunch carries `skipifsilent` — which a
/// console-initiated update, spawned with `/VERYSILENT`, always trips. The tray therefore died
/// on every in-console update and stayed dead until the next sign-in. The installer cannot fix
/// this itself: spawned from the SYSTEM host service, its `runasoriginaluser` resolves to
/// SYSTEM, which would put a SYSTEM-owned tray in the user's session squatting the
/// `Local\PunktfunkTray` mutex and blocking the real one. The host relaunches it instead — it
/// already owns the `WTSQueryUserToken` primitive for landing a process in the interactive
/// session as the logged-in user.
///
/// `#[serde(default)]`: an intent written by an older host reads as false (no relaunch), which
/// is the pre-existing behaviour.
#[serde(default)]
pub tray_was_running: bool,
}
/// The durable outcome of the most recent apply attempt.
@@ -189,9 +206,21 @@ mod tests {
installer_sha256: "ab".repeat(32),
log_path: "/logs/update-0.23.200.log".into(),
source_build: false,
tray_was_running: false,
}
}
/// An intent written by a host from before the tray-relaunch field must still load, and read
/// as "no tray to put back" — the behaviour that shipped before it existed.
#[test]
fn an_intent_without_the_tray_field_deserializes_as_false() {
let json = r#"{"from":"0.23.100","to":"0.23.200","serial":42,"started_unix":1000,
"installer_sha256":"ab","log_path":"/logs/x.log"}"#;
let i: IntentRecord = serde_json::from_str(json).expect("older intent still parses");
assert!(!i.tray_was_running);
assert!(!i.source_build);
}
#[test]
fn source_build_intent_is_success_with_the_running_version() {
let mut i = intent(0);
@@ -140,6 +140,9 @@ pub(super) fn run_apply_steamos(
installer_sha256: String::new(),
log_path: log.display().to_string(),
source_build: true,
// Windows-only concern: no Linux package force-kills a running tray, and the desktop
// autostart entry owns bringing it up.
tray_was_running: false,
},
)
.map_err(|e| ("applying", format!("write intent record: {e}")))?;
@@ -349,6 +352,7 @@ pub(super) fn run_apply(
installer_sha256: String::new(),
log_path: "journalctl -u punktfunk-update.service".into(),
source_build: false,
tray_was_running: false, // Windows-only concern (see the source-build intent above)
},
)
.map_err(|e| ("restarting", format!("write intent record: {e}")))?;
@@ -43,6 +43,24 @@ fn staging_dir() -> PathBuf {
pf_paths::config_dir().join("updates")
}
/// Put the per-user tray back after an update that killed it.
///
/// Runs from boot reconciliation, i.e. in the NEW host, as SYSTEM — so `crate::tray::start` takes
/// its session-crossing path and lands the tray in the active console session under the logged-in
/// user's own token. That is the whole reason this is the host's job and not the installer's (see
/// `IntentRecord::tray_was_running`).
///
/// Best-effort throughout: nobody's update outcome depends on the icon, and the common benign
/// failure is simply that nobody has signed in yet — which the HKLM `Run` value covers at the next
/// logon. Hence `info`, not a warning the operator must act on.
pub(crate) fn relaunch_tray() {
match crate::tray::start() {
Ok((Some(pid), how)) => tracing::info!(pid, how, "status tray relaunched after the update"),
Ok((None, _)) => tracing::debug!("status tray was already running after the update"),
Err(e) => tracing::info!(error = %e, "could not relaunch the status tray after the update"),
}
}
fn log_path(version: &str) -> PathBuf {
pf_paths::config_dir()
.join("logs")
@@ -96,6 +114,10 @@ pub(super) fn run_apply(
installer_sha256: asset.sha256.to_ascii_lowercase(),
log_path: log.display().to_string(),
source_build: false,
// Captured BEFORE the installer runs: it force-kills every tray to unlock
// punktfunk-tray.exe and, under /VERYSILENT, never runs its relaunch entry. See
// `IntentRecord::tray_was_running`; `relaunch_tray` puts it back at reconcile.
tray_was_running: crate::tray::is_running(),
},
)
.map_err(|e| ("applying", format!("write intent record: {e}")))?;