Skip to content

Commit f1321a8

Browse files
pmaxhoganclaude
andauthored
fix(updater): shut down the VSS helper before applying an update (#126)
Closes #125. ## Problem During 2.0.0 release QA, applying an app update failed with the NSIS installer error `Error opening file for writing: ...\driven-vss-helper.exe` (Abort/Retry/Ignore) when the elevated VSS helper broker was running. ## Root-cause trace - On Windows, `tauri-plugin-updater`'s `download_and_install` runs the **NSIS installer synchronously** with `/P /R` (passive + restart) - it overwrites the bundle files, including the bundled `driven-vss-helper.exe` sidecar, in that call. - Tauri's NSIS `Section Install` runs `NSIS_HOOK_PREINSTALL`, then `CheckIfAppIsRunning "${MAINBINARYNAME}.exe"` - it only terminates the **main** binary (`driven-app.exe`), **never the sidecar**. A running elevated helper holds an open handle to its own exe, so the overwrite fails. - **Neither updater path ever swept the helper before the installer ran.** Only the app-quit sweep (`state.shutdown_vss_helper()` in `run_on_exit`) shuts the broker down. So: - The **dev-channel silent** path (`silent_install_dev_update`, from the periodic task) and the **manual** `install_update` path both called `download_and_install` with the broker still alive. - Because `/R` force-restarts the app **without** going through `run_on_exit`, the pre-fix update paths also **seeded the orphan**: the app restarted and left the elevated helper running, so the *next* update tripped over the "helper from a previous app session" the QA note describes. (This is why the QA repro shows a previous-session helper, not just a same-session one.) ## Fix **1. App-side (primary).** Both updater paths now call `AppState::shutdown_vss_helper_for_update()` *before* `download_and_install`. Unlike the app-quit sweep (a bare `shutdown()`), it uses `set_enabled(false)` - a **superset** that: - performs the same Shutdown+reap, including abandoning + reaping a `Pending` launch per the #113 generation semantics, and - **disables** the manager so a still-running sync that hits a locked file cannot **re-launch** the elevated broker (re-locking the exe) during the potentially-long download window. On a **failed** install (app keeps running) the helper is **re-armed** (`rearm_vss_helper_after_failed_update`, a lazy re-enable - no forced UAC) so locked-file backup is not left silently degraded. On success the app restarts, so a fresh process rebuilds the manager. **2. Installer-side (belt-and-braces).** `NSIS_HOOK_PREINSTALL` (`src-tauri/installer-hooks.nsh`, wired via `bundle.windows.nsis.installerHooks`) `taskkill /F /IM driven-vss-helper.exe`, error-tolerated, before any file is copied. **3. Tests.** `update_sweep_reaps_ready_helper_and_blocks_relaunch` (Ready broker -> reaped + disabled + no relaunch) and `shutdown_vss_helper_for_update_disables_then_rearm_restores` (AppState seam: disable then re-arm), using the injected launcher/manager doubles from #112/#113. The *ordering* of the sweep relative to `download_and_install` is verified by placement + review (the real `Update`/download cannot be faked in a unit test); the NSIS hook is CI-verified by the dev-channel/release bundle builds. ## Scope / findings to note - **NSIS taskkill has a real limitation (documented in the .nsh).** The helper always runs **elevated** (it refuses to start un-elevated), and the default NSIS `installMode` is `currentUser`, so the updater-spawned installer runs **un-elevated**. A medium-integrity `taskkill /F` cannot terminate the high-integrity helper (access-denied), so the hook only bites for an elevated/`perMachine`/admin-run install. The **effective** same-session fix is the app-side pipe Shutdown; the durable fix for a **crash-orphaned** elevated helper (app killed without any sweep) is a **parent-death watchdog in the helper** (it has none today - it only exits on an explicit pipe `Shutdown`). Recommend filing that as a follow-up. - **MSI target unaffected / no equivalent needed.** The Windows auto-updater installs via the **NSIS** artifact (`*-setup.exe`), not the `.msi` (`generate-update-json.mjs` prefers the `.exe`, matching tauri-action's default). The `.msi` is only for manual installs, where Windows Installer's Restart Manager already handles in-use files. So no MSI-side change. - **Pre-existing comment inaccuracy (left out of scope).** `silent_install_dev_update`'s comment says it "does NOT force a restart - the staged update applies on the next Driven restart", but NSIS Passive = `/P /R` = restart. The dev channel force-restarts on every update. Flagging as a follow-up; behavior unchanged here. ## Gates `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace` all green. No UI or workflow files touched. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LQMbCLUj5JT2e35qQMvsyA Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 45096fd commit f1321a8

5 files changed

Lines changed: 208 additions & 0 deletions

File tree

src-tauri/installer-hooks.nsh

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
; NSIS installer hooks for the Driven Windows bundle (issue #125).
2+
;
3+
; Tauri's NSIS installer template inserts NSIS_HOOK_PREINSTALL at the very top of
4+
; the Install section - before SetOutPath and the first File write, and before
5+
; its own CheckIfAppIsRunning step. That built-in kill targets ONLY the main
6+
; binary (driven-app.exe); it never touches the bundled driven-vss-helper.exe
7+
; sidecar. If an elevated VSS helper broker is still running (a same-session
8+
; broker, or an orphan from a crashed / force-restarted prior session), it holds
9+
; an open handle to its own exe and the install aborts with:
10+
;
11+
; Error opening file for writing: ...\driven-vss-helper.exe [Abort/Retry/Ignore]
12+
;
13+
; Belt-and-braces: force-terminate any lingering helper before files are copied.
14+
; Error tolerated - taskkill exits non-zero when no such process exists, which is
15+
; the normal (no helper running) case; we discard the exit code with `Pop $0`.
16+
;
17+
; LIMITATION (see the PR for #125): the helper always runs ELEVATED (it refuses to
18+
; start un-elevated), and the default NSIS install mode is `currentUser`, so the
19+
; updater-spawned installer runs UN-elevated. A medium-integrity taskkill cannot
20+
; terminate the high-integrity helper (access-denied), so this hook only bites
21+
; when the installer itself is elevated (an admin-run / perMachine install). The
22+
; primary fix for the same-session case is the app-side pipe Shutdown before
23+
; `download_and_install`; the durable fix for a crash-orphaned elevated helper is
24+
; tracked as a follow-up (a parent-death watchdog in the helper).
25+
26+
!macro NSIS_HOOK_PREINSTALL
27+
nsExec::Exec '"$SYSDIR\taskkill.exe" /F /IM driven-vss-helper.exe'
28+
Pop $0
29+
!macroend

src-tauri/src/app_state.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,53 @@ impl AppState {
570570
}
571571
}
572572

573+
/// Shut the VSS helper broker down BEFORE an in-app update installer runs
574+
/// (issue #125), returning whether a manager was disabled (so the caller can
575+
/// RE-ARM it if the install then fails - see
576+
/// [`Self::rearm_vss_helper_after_failed_update`]).
577+
///
578+
/// The Windows NSIS updater overwrites the bundled `driven-vss-helper.exe`
579+
/// sidecar, but its stock process-kill only targets the MAIN binary
580+
/// (`driven-app.exe`) - never the sidecar. A running elevated broker holds an
581+
/// open handle to its own exe, so the install fails with "Error opening file
582+
/// for writing: ...driven-vss-helper.exe". `download_and_install` runs the
583+
/// NSIS installer synchronously (`/P /R`), so the broker must be gone BEFORE
584+
/// that call.
585+
///
586+
/// Unlike the app-quit [`Self::shutdown_vss_helper`] (a bare `shutdown()`),
587+
/// this uses `set_enabled(false)`, which is a SUPERSET: it performs the same
588+
/// Shutdown+reap (including abandoning + reaping a `Pending` launch per the
589+
/// #113 generation semantics) AND disables the manager so a still-running
590+
/// sync that hits a locked file cannot RE-LAUNCH the elevated broker (and
591+
/// re-lock the exe) during the potentially-long `download_and_install`
592+
/// window. A memoised session decline is reset to `NotAttempted` by the
593+
/// underlying shutdown; that is inherent to any shutdown-based sweep and
594+
/// harmless here (an update is user-consented and a successful install
595+
/// restarts the app anyway).
596+
///
597+
/// Best-effort + idempotent: a no-op (returns `false`) when no manager is in
598+
/// play (off Windows / elevated / setting off).
599+
pub fn shutdown_vss_helper_for_update(&self) -> bool {
600+
if let Some(manager) = self.vss_helper_manager() {
601+
manager.set_enabled(false);
602+
true
603+
} else {
604+
false
605+
}
606+
}
607+
608+
/// Re-arm the VSS helper broker after a FAILED update install (issue #125):
609+
/// the app keeps running, so undo the
610+
/// [`Self::shutdown_vss_helper_for_update`] disable so locked-file backup is
611+
/// available again on demand (a LAZY re-launch on the next locked file - no
612+
/// forced UAC prompt), rather than staying silently degraded until the next
613+
/// app restart. Best-effort + idempotent; a no-op when no manager is in play.
614+
pub fn rearm_vss_helper_after_failed_update(&self) {
615+
if let Some(manager) = self.vss_helper_manager() {
616+
manager.set_enabled(true);
617+
}
618+
}
619+
573620
// --- M9c D4: recovery-phrase ACK gate (M6 R4-P1-1, DATA-SAFETY) ---------
574621

575622
/// Lock the recovery-ack map, recovering a poisoned lock (house rule: never
@@ -1659,6 +1706,59 @@ mod tests {
16591706
let _ = std::fs::remove_dir_all(dir);
16601707
}
16611708

1709+
#[tokio::test]
1710+
async fn shutdown_vss_helper_for_update_disables_then_rearm_restores() {
1711+
// Issue #125: the updater path disables the broker BEFORE
1712+
// `download_and_install` so a live/relaunching elevated helper cannot hold
1713+
// its own exe open while the NSIS installer overwrites it. On a FAILED
1714+
// install (app keeps running) the caller re-arms it so locked-file backup
1715+
// is not left silently degraded.
1716+
use driven_vss_helper::HelperLauncher; // brings `is_available` into scope
1717+
let (state, dir) = temp_state().await;
1718+
let app_state = AppState::new(
1719+
state,
1720+
HashMap::new(),
1721+
RemoteMode::Fake,
1722+
default_fake_registry(),
1723+
);
1724+
1725+
// No manager: both calls are safe no-ops; the pre-install sweep reports it
1726+
// disabled nothing.
1727+
assert!(!app_state.shutdown_vss_helper_for_update());
1728+
app_state.rearm_vss_helper_after_failed_update();
1729+
1730+
// Install an ENABLED manager with an injected launch (no real UAC /
1731+
// process). Not launched yet -> NotAttempted + enabled == launchable.
1732+
let manager = Arc::new(crate::vss_helper::VssHelperManager::with_launch_fn(
1733+
std::env::temp_dir().join("driven-vss-helper.exe"),
1734+
std::env::temp_dir(),
1735+
true,
1736+
Box::new(|| Ok(())),
1737+
));
1738+
app_state.set_vss_helper_manager(manager.clone());
1739+
assert!(
1740+
manager.is_available(),
1741+
"an enabled, not-yet-tried broker is available on demand"
1742+
);
1743+
1744+
// The pre-install sweep disables it (so a mid-download locked file cannot
1745+
// re-launch the broker) and reports it acted.
1746+
assert!(app_state.shutdown_vss_helper_for_update());
1747+
assert!(
1748+
!manager.is_available(),
1749+
"disabled broker is not available -> cannot re-launch during the install"
1750+
);
1751+
1752+
// A failed install re-arms it so backup is available again on demand.
1753+
app_state.rearm_vss_helper_after_failed_update();
1754+
assert!(
1755+
manager.is_available(),
1756+
"re-arm after a failed install restores on-demand launchability"
1757+
);
1758+
1759+
let _ = std::fs::remove_dir_all(dir);
1760+
}
1761+
16621762
#[tokio::test]
16631763
async fn recovery_ack_gate_requires_a_recorded_backend_reveal() {
16641764
// M9c D4 (M6 R4-P1-1, DATA-SAFETY): the ack gate `ack_recovery_phrase_saved`

src-tauri/src/updater.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,12 +604,28 @@ async fn silent_install_dev_update(
604604
}
605605
};
606606

607+
// Issue #125: shut the elevated VSS helper broker down BEFORE the installer
608+
// runs (same reason as the manual `install_update` path). The dev-silent path
609+
// ran `download_and_install` from the periodic task WITHOUT the app-quit
610+
// sweep, so a live broker either blocked the install or was left orphaned when
611+
// the installer restarted the app - the exact QA repro. Disabling also blocks
612+
// a mid-download re-launch; re-armed on failure since the app keeps running.
613+
let vss_disabled = app
614+
.try_state::<AppState>()
615+
.map(|s| s.shutdown_vss_helper_for_update())
616+
.unwrap_or(false);
617+
607618
match update.download_and_install(on_chunk, on_done).await {
608619
Ok(()) => {
609620
tracing::info!(target: TARGET, version = %info.version, "dev update installed silently; applies on next restart");
610621
crate::tray::notify_dev_update_installed(app, &info.version);
611622
}
612623
Err(e) => {
624+
if vss_disabled {
625+
if let Some(s) = app.try_state::<AppState>() {
626+
s.rearm_vss_helper_after_failed_update();
627+
}
628+
}
613629
tracing::warn!(target: TARGET, error = %e, version = %info.version, "dev silent update install failed (will retry next interval)");
614630
}
615631
}
@@ -688,6 +704,18 @@ pub async fn install_update(app: AppHandle, state: State<'_, AppState>) -> Comma
688704
// The display channel for the downloaded event (R1-P2-3).
689705
let channel = Channel::from_str_lenient(&downloaded_channel(&channel_str));
690706

707+
// Issue #125: shut the elevated VSS helper broker down BEFORE the installer
708+
// runs. `download_and_install` executes the NSIS installer synchronously
709+
// (`/P /R`), which overwrites the bundled `driven-vss-helper.exe` sidecar -
710+
// but the stock installer only terminates the MAIN binary, so a live broker
711+
// holds its own exe open and the install fails with "Error opening file for
712+
// writing: ...driven-vss-helper.exe". Disabling (not a bare shutdown) also
713+
// blocks a still-running sync from re-launching the broker mid-download. This
714+
// ALSO stops the update path itself from seeding an orphan: the pre-fix path
715+
// let `/R` restart the app WITHOUT ever sweeping the helper. Re-armed below
716+
// if the install fails (the app keeps running).
717+
let vss_disabled = state.shutdown_vss_helper_for_update();
718+
691719
// The progress callback accumulates downloaded bytes and emits
692720
// `updater:download_progress`. `content_length` arrives once the server
693721
// reports it; until then `total` is None.
@@ -718,6 +746,12 @@ pub async fn install_update(app: AppHandle, state: State<'_, AppState>) -> Comma
718746
// R1-P2-2: restore the pending update (with its channel) so the user can
719747
// retry without re-checking.
720748
state.set_pending_update(Some((update, channel_str)));
749+
// Issue #125: the install failed and the app keeps running - re-arm the
750+
// VSS helper we disabled above so locked-file backup is not left silently
751+
// degraded for the rest of the session.
752+
if vss_disabled {
753+
state.rearm_vss_helper_after_failed_update();
754+
}
721755
}
722756
install_result.map_err(map_install_error)?;
723757

src-tauri/src/vss_helper.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -734,6 +734,48 @@ mod tests {
734734
);
735735
}
736736

737+
#[test]
738+
fn update_sweep_reaps_ready_helper_and_blocks_relaunch() {
739+
// Issue #125: the updater's pre-install sweep (AppState
740+
// `shutdown_vss_helper_for_update`) calls `set_enabled(false)`. On a Ready
741+
// broker this must (1) reap the running helper - directly, like the quit
742+
// sweep - so it releases its exe handle before the NSIS installer
743+
// overwrites it, AND (2) DISABLE the manager so a still-running sync that
744+
// hits a locked file cannot RE-LAUNCH the elevated broker (re-locking the
745+
// exe) during the `download_and_install` window.
746+
let (launch, calls) = counting_launch(Ok(()));
747+
let mgr = manager(true, launch);
748+
mgr.launch_now();
749+
mgr.join_launch_thread();
750+
assert!(
751+
mgr.helper_alive(),
752+
"the broker is up before the update sweep"
753+
);
754+
755+
// The pre-install sweep: disable + reap.
756+
mgr.set_enabled(false);
757+
assert!(
758+
!mgr.helper_alive(),
759+
"the broker is shut down before the installer runs"
760+
);
761+
assert!(!mgr.is_available(), "a disabled broker is not available");
762+
assert_eq!(mgr.launch_status(), LaunchStatus::Disabled);
763+
// A Ready broker is reaped by the shutdown path directly (not the
764+
// abandoned-launch resolver), so no reap is counted there.
765+
assert_eq!(mgr.reap_count(), 0);
766+
767+
// A locked file mid-download consults the launcher: it must NOT re-launch
768+
// the elevated broker while the install is in flight.
769+
let before = calls.load(Ordering::SeqCst);
770+
mgr.launch_now();
771+
mgr.join_launch_thread();
772+
assert_eq!(
773+
calls.load(Ordering::SeqCst),
774+
before,
775+
"a disabled broker must not re-launch during the install window"
776+
);
777+
}
778+
737779
/// Exercise the PRODUCTION constructor + launch path (`new` -> `production_launch`
738780
/// -> `launch_elevated`) without a real UAC prompt. Gated to non-Windows: there
739781
/// `launch_elevated` reports "Windows only" immediately (no `runas`, no prompt),

src-tauri/tauri.conf.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@
7676
"windows": {
7777
"wix": {
7878
"language": "en-US"
79+
},
80+
"nsis": {
81+
"installerHooks": "installer-hooks.nsh"
7982
}
8083
}
8184
}

0 commit comments

Comments
 (0)