Skip to content

Single-instance upgrade handoff: close the stale instance instead of surfacing it#1148

Merged
erikdarlingdata merged 1 commit into
devfrom
feature/single-instance-upgrade-handoff
Jun 19, 2026
Merged

Single-instance upgrade handoff: close the stale instance instead of surfacing it#1148
erikdarlingdata merged 1 commit into
devfrom
feature/single-instance-upgrade-handoff

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Problem

The apps are single-instance (constant mutex) and minimize to tray, so an old build keeps running after the user "closes" it. Launching the new build just surfaced the old in-memory version via the mutex — so an upgrade looked like it "didn't take." (Tester: ran setup, reopened, got the old version; only a full tray-exit + uninstall/reinstall worked.)

Fix

Version-aware single-instance handoff at startup, in a shared SingleInstanceCoordinator (PerformanceMonitor.Ui) used by both apps:

  • Older running build → prompt → close it gracefully (it runs its real shutdown: flush DuckDB, dispose tray, release mutex) → take over. Same/newer → surface + exit (today's behavior). Older-but-elevated (can't signal/kill across integrity) → actionable error + "Restart as administrator" (gated to split-token admins).
  • Version read from the running instance's on-disk exe via QueryFullProcessImageNameW (cross-integrity, same user); integrity measured directly (token IL). Compares the release version (<Version> → ProductVersion), not FileVersion/AssemblyVersion.
  • Scoped by exe name so Lite never targets Dashboard and vice-versa. Local\ session scoping kept intentionally.
  • Coordinator runs synchronously at the top of OnStartup before any window/DB/port init; the exit-for-upgrade channel opens only after init so a newer build won't disturb a mid-initializing instance.

Covers all delivery paths (Velopack Setup relaunch, in-app update, portable ZIP); the in-app ApplyUpdatesAndRestart path already hard-exits the old process, so it's unaffected.

Review

Two adversarial plan passes + one implementation pass; all findings folded in (version-field critical; mutex-throw-vs-elevated → integrity-error path not crash; deferred exit listener; direct IL measure; runas gated; UAC-cancel handled; handles disposed).

Tests

Lite 524 + Dashboard 487 green; 0 new warnings. 19 new decision unit tests. Manual upgrade/elevation smoke testing to be done on the build.

Design: plans/single-instance-upgrade-handoff.md.

🤖 Generated with Claude Code

…surfacing it

Upgrading the app appeared not to take effect: the apps are single-instance
(constant mutex) and minimize to tray, so an old build kept running after the
user "closed" it, and launching the new build just surfaced the old in-memory
version via the mutex. Fix: a version-aware handoff at startup — a newer build
closes an older tray-resident one and takes over, instead of being handed back
the stale version.

Shared PerformanceMonitor.Ui:
- SingleInstanceDecision: pure, unit-tested decision (older->take over;
  same/newer->surface; older-but-higher-integrity->actionable error).
- ProcessInspector: Win32 — read the other instance's release version from its
  on-disk exe (QueryFullProcessImageNameW, cross-integrity for same user),
  measure integrity level directly, detect split-token admin. Fails closed.
- SingleInstanceCoordinator: acquire-or-handoff; prompt; graceful exit signal
  (old runs its real shutdown), bounded wait, force-kill last resort; mutex
  take-over; elevated relaunch (--upgrade-takeover) for the elevated-old case.
- MessageBoxHandoffPrompts: shared dialogs (both apps, parity).

Both apps: OnStartup runs the coordinator (replacing the inline mutex/surface
block) synchronously before any window/DB/port init; OnExit disposes it;
MainWindow opens the exit-for-upgrade channel only after init (so a newer build
won't disturb a mid-initializing instance). Scoped by exe name so Lite never
targets Dashboard and vice-versa. Local\ session scoping kept intentionally.

Two adversarial plan reviews + one implementation review folded in (version
field = ProductVersion not FileVersion; mutex-throw vs an elevated instance ->
integrity-error path not crash; deferred exit listener; direct IL measure;
runas gated to split-token admins; UAC-cancel handled; handles disposed).

Tests: Lite 524 + Dashboard 487 green; 0 new warnings. Manual smoke testing of
the upgrade/elevation paths still required before merge (see
plans/single-instance-upgrade-handoff.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@erikdarlingdata erikdarlingdata merged commit 6ad367c into dev Jun 19, 2026
2 checks passed
@erikdarlingdata erikdarlingdata deleted the feature/single-instance-upgrade-handoff branch June 19, 2026 01:06
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.

1 participant