3636//! whole state machine is unit-tested cross-OS without a real `runas`/UAC prompt.
3737
3838use std:: path:: { Path , PathBuf } ;
39- use std:: sync:: atomic:: { AtomicBool , Ordering } ;
39+ use std:: sync:: atomic:: { AtomicBool , AtomicU64 , AtomicUsize , Ordering } ;
4040use std:: sync:: { Arc , Mutex } ;
4141use std:: thread:: JoinHandle ;
4242use 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
107117impl 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
394439impl 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