Skip to content

Commit b05b568

Browse files
pmaxhoganclaude
andcommitted
fix: reap an abandoned in-flight VSS helper launch on quit/disable
Close the quit/disable-during-Pending orphan hole the team lead flagged: if the app quit (or the user disabled the helper) WHILE a launch was Pending (UAC still up) and the user then approved late, the just-launched ELEVATED helper came up with nobody to Shutdown it and lingered on its pipe - exactly the always-on elevated attack surface the DESIGN model must not leave behind. - A monotonic launch generation (bumped under the state lock on every launch trigger AND every shutdown/disable) lets the resolver thread detect that its launch was abandoned: it applies its result only if still current, else it REAPS the helper it brought up (connect + Shutdown) instead of leaving it, and does not touch the state. The generation check + state write are atomic under the lock, so a shutdown racing a resolving launch reaps exactly once (either shutdown reaps a now-Ready helper, or the resolver reaps its own). - shutdown() now also resets state to NotAttempted (so re-enable relaunches) and is the single abandonment primitive set_enabled(false) reuses. - Tests (fake launcher, cross-OS): shutdown-during-Pending -> the late-resolving launch reaps (reap_count == 1, not left Ready); Ready-before-shutdown -> reaped by shutdown, resolver does not double-reap (reap_count == 0). Refs #25 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LQMbCLUj5JT2e35qQMvsyA
1 parent 8b1e25a commit b05b568

1 file changed

Lines changed: 165 additions & 43 deletions

File tree

src-tauri/src/vss_helper.rs

Lines changed: 165 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
//! whole state machine is unit-tested cross-OS without a real `runas`/UAC prompt.
3737
3838
use std::path::{Path, PathBuf};
39-
use std::sync::atomic::{AtomicBool, Ordering};
39+
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
4040
use std::sync::{Arc, Mutex};
4141
use std::thread::JoinHandle;
4242
use std::time::Duration;
@@ -102,6 +102,16 @@ struct Inner {
102102
/// Whether the `windows.vss_helper` setting is on (gates all launching +
103103
/// capability). Updated live by [`VssHelperManager::set_enabled`].
104104
enabled: AtomicBool,
105+
/// Monotonic launch generation. Bumped (under the `state` lock) on every
106+
/// launch trigger AND on every shutdown/disable. A background launch captures
107+
/// its generation; when it resolves it applies its result ONLY if the
108+
/// generation is still current - otherwise it was ABANDONED (a quit/disable
109+
/// happened while it was Pending) and it REAPS the elevated helper it brought
110+
/// up instead of leaving an orphaned elevated process holding the pipe.
111+
generation: AtomicU64,
112+
/// Count of abandoned launches whose helper the resolver reaped (test
113+
/// observability; cheap in production).
114+
reap_count: AtomicUsize,
105115
}
106116

107117
impl Inner {
@@ -197,6 +207,8 @@ impl VssHelperManager {
197207
launch,
198208
state: Mutex::new(LaunchState::NotAttempted),
199209
enabled: AtomicBool::new(enabled),
210+
generation: AtomicU64::new(0),
211+
reap_count: AtomicUsize::new(0),
200212
}),
201213
launch_thread: Mutex::new(None),
202214
}
@@ -248,10 +260,10 @@ impl VssHelperManager {
248260
*st = LaunchState::NotAttempted;
249261
}
250262
} else {
251-
// Disabled: stop the broker (best-effort) and reset so re-enable
252-
// relaunches cleanly.
263+
// Disabled: stop the broker (best-effort), abandon any in-flight
264+
// launch (so it reaps the helper it brings up), and reset the state so
265+
// a later re-enable relaunches cleanly. `shutdown` does all three.
253266
self.shutdown();
254-
*self.inner.lock_state() = LaunchState::NotAttempted;
255267
}
256268
}
257269

@@ -272,38 +284,65 @@ impl VssHelperManager {
272284
if !self.is_enabled() {
273285
return;
274286
}
275-
{
287+
// Capture THIS launch's generation under the state lock, so a concurrent
288+
// shutdown/disable that bumps the generation is ordered against it.
289+
let my_gen = {
276290
let mut st = self.inner.lock_state();
277291
if *st != LaunchState::NotAttempted {
278292
return; // already pending / ready / declined / transiently-failed
279293
}
280294
*st = LaunchState::Pending;
281-
}
295+
self.inner
296+
.generation
297+
.fetch_add(1, Ordering::SeqCst)
298+
.wrapping_add(1)
299+
};
282300
let inner = self.inner.clone();
283301
let handle = std::thread::Builder::new()
284302
.name("driven-vss-launch".to_string())
285303
.spawn(move || {
286304
let outcome = (inner.launch)();
287-
let next = match outcome {
288-
Ok(()) => {
289-
tracing::info!("VSS helper: elevated broker launched + serving");
290-
LaunchState::Ready
291-
}
292-
Err(LaunchError::Declined) => {
293-
tracing::warn!(
294-
"VSS helper: elevation declined/ignored; locked-file backup stays degraded this session (no re-prompt)"
295-
);
296-
LaunchState::Declined
297-
}
298-
Err(LaunchError::Failed(detail)) => {
299-
tracing::warn!(
300-
error = %detail,
301-
"VSS helper: launch did not come up (transient); will retry on the next enable/start"
302-
);
303-
LaunchState::FailedTransient
305+
// Apply the result ONLY if this launch is still the current
306+
// generation; the check + state write are atomic under the lock so
307+
// a shutdown that bumped the generation cannot interleave.
308+
let abandoned_ok = {
309+
let mut st = inner.lock_state();
310+
if inner.generation.load(Ordering::SeqCst) != my_gen {
311+
// Superseded / abandoned (a quit/disable happened while we
312+
// were Pending). Do NOT touch state; if we brought a helper
313+
// up, reap it below (outside the lock).
314+
outcome.is_ok()
315+
} else {
316+
*st = match &outcome {
317+
Ok(()) => {
318+
tracing::info!("VSS helper: elevated broker launched + serving");
319+
LaunchState::Ready
320+
}
321+
Err(LaunchError::Declined) => {
322+
tracing::warn!(
323+
"VSS helper: elevation declined/ignored; locked-file backup stays degraded this session (no re-prompt)"
324+
);
325+
LaunchState::Declined
326+
}
327+
Err(LaunchError::Failed(detail)) => {
328+
tracing::warn!(
329+
error = %detail,
330+
"VSS helper: launch did not come up (transient); will retry on the next enable/start"
331+
);
332+
LaunchState::FailedTransient
333+
}
334+
};
335+
false
304336
}
305337
};
306-
*inner.lock_state() = next;
338+
if abandoned_ok {
339+
// The app quit / disabled the helper while this launch was in
340+
// flight, then it came up: SHUT IT DOWN so no orphaned elevated
341+
// process lingers on the pipe (the always-on elevated attack
342+
// surface the DESIGN model must not leave behind).
343+
inner.reap_count.fetch_add(1, Ordering::SeqCst);
344+
reap_helper(&inner);
345+
}
307346
});
308347
match handle {
309348
Ok(h) => {
@@ -353,26 +392,26 @@ impl VssHelperManager {
353392
}
354393

355394
/// Shut the broker down (release everything + exit) at app quit / on disable.
356-
/// Best-effort + idempotent: a no-op unless the broker is up. Off Windows the
357-
/// client is not compiled, so this is a no-op there too.
395+
/// Best-effort + idempotent.
396+
///
397+
/// Two jobs, both closing the orphaned-elevated-process hole: (1) bump the
398+
/// launch generation so ANY in-flight launch that resolves AFTER this reaps
399+
/// the helper it brings up instead of leaving it (the quit/disable-during-
400+
/// Pending race); (2) if a helper is currently up (`Ready`), shut it down now.
401+
/// Resets the state to `NotAttempted` so a later re-enable relaunches cleanly.
358402
pub fn shutdown(&self) {
359-
#[cfg(windows)]
360-
{
361-
if *self.inner.lock_state() != LaunchState::Ready {
362-
return;
363-
}
364-
match driven_vss_helper::HelperClient::connect(
365-
&self.inner.pipe_name,
366-
&self.inner.helper_dir,
367-
) {
368-
Ok(mut c) => {
369-
let _ = c.shutdown();
370-
tracing::info!("VSS helper: shutdown requested");
371-
}
372-
Err(e) => {
373-
tracing::debug!(error = %e, "VSS helper: shutdown connect failed (broker may have already exited)");
374-
}
375-
}
403+
// Bump the generation + read/reset the state atomically, so an in-flight
404+
// resolver either already applied `Ready` (then we reap it below) or sees
405+
// the new generation and reaps itself.
406+
let was_ready = {
407+
let mut st = self.inner.lock_state();
408+
self.inner.generation.fetch_add(1, Ordering::SeqCst);
409+
let ready = *st == LaunchState::Ready;
410+
*st = LaunchState::NotAttempted;
411+
ready
412+
};
413+
if was_ready {
414+
reap_helper(&self.inner);
376415
}
377416
}
378417

@@ -389,6 +428,12 @@ impl VssHelperManager {
389428
let _ = h.join();
390429
}
391430
}
431+
432+
/// Test-only: how many abandoned launches the resolver reaped.
433+
#[cfg(test)]
434+
fn reap_count(&self) -> usize {
435+
self.inner.reap_count.load(Ordering::SeqCst)
436+
}
392437
}
393438

394439
impl HelperLauncher for VssHelperManager {
@@ -427,6 +472,30 @@ impl HelperLauncher for VssHelperManager {
427472
}
428473
}
429474

475+
/// Shut down a broker that an ABANDONED launch brought up (best-effort). Called
476+
/// off the state lock. Windows-only: the client is not compiled elsewhere, so
477+
/// this is a no-op there (and the manager is never in play off Windows anyway).
478+
fn reap_helper(inner: &Inner) {
479+
#[cfg(windows)]
480+
{
481+
match driven_vss_helper::HelperClient::connect(&inner.pipe_name, &inner.helper_dir) {
482+
Ok(mut c) => {
483+
let _ = c.shutdown();
484+
tracing::info!(
485+
"VSS helper: reaped an abandoned broker (quit/disable landed while it was launching)"
486+
);
487+
}
488+
Err(e) => {
489+
tracing::debug!(error = %e, "VSS helper: reap connect failed (broker may not have come up)");
490+
}
491+
}
492+
}
493+
#[cfg(not(windows))]
494+
{
495+
let _ = inner;
496+
}
497+
}
498+
430499
/// The production "bring the broker to Ready" operation: launch elevated (which,
431500
/// with `SEE_MASK_NOASYNC`, blocks until the user approves/declines), then - once
432501
/// approved - probe the pipe until the broker is serving, up to [`ATTENDED_WINDOW`].
@@ -612,6 +681,59 @@ mod tests {
612681
mgr.shutdown(); // NotAttempted -> no-op, must not panic/connect
613682
}
614683

684+
#[test]
685+
fn shutdown_during_pending_reaps_the_helper_that_resolves_after() {
686+
// The quit/disable-during-Pending race: a launch is in flight (UAC up);
687+
// shutdown lands; then the launch RESOLVES (the user approved late). The
688+
// resolver must REAP the just-launched elevated helper instead of leaving
689+
// it as an orphan holding the pipe - and NOT leave the state Ready.
690+
let (tx, rx) = mpsc::channel::<()>();
691+
let rx = Arc::new(Mutex::new(rx));
692+
let launch: LaunchFn = Box::new(move || {
693+
let _ = rx.lock().unwrap().recv(); // block until released
694+
Ok(()) // then "come up"
695+
});
696+
let mgr = manager(true, launch);
697+
mgr.launch_now();
698+
assert!(mgr.launch_pending(), "launch is in flight");
699+
700+
// Quit/disable lands while Pending.
701+
mgr.shutdown();
702+
703+
// The launch resolves AFTER the shutdown.
704+
tx.send(()).unwrap();
705+
mgr.join_launch_thread();
706+
707+
assert!(
708+
!mgr.helper_alive(),
709+
"an abandoned launch must NOT be left Ready"
710+
);
711+
assert_eq!(
712+
mgr.reap_count(),
713+
1,
714+
"the resolver must reap the helper it brought up after the shutdown"
715+
);
716+
}
717+
718+
#[test]
719+
fn shutdown_after_ready_reaps_without_double_counting() {
720+
// The other ordering: the launch reaches Ready BEFORE shutdown. Shutdown
721+
// reaps the running helper directly; the resolver already applied Ready and
722+
// does not also reap (no double reap).
723+
let (launch, _) = counting_launch(Ok(()));
724+
let mgr = manager(true, launch);
725+
mgr.launch_now();
726+
mgr.join_launch_thread();
727+
assert!(mgr.helper_alive());
728+
mgr.shutdown();
729+
assert!(!mgr.helper_alive(), "shutdown resets the state");
730+
assert_eq!(
731+
mgr.reap_count(),
732+
0,
733+
"a launch that reached Ready before shutdown is reaped BY shutdown, not the resolver"
734+
);
735+
}
736+
615737
/// Exercise the PRODUCTION constructor + launch path (`new` -> `production_launch`
616738
/// -> `launch_elevated`) without a real UAC prompt. Gated to non-Windows: there
617739
/// `launch_elevated` reports "Windows only" immediately (no `runas`, no prompt),

0 commit comments

Comments
 (0)