99
1010use std:: collections:: HashMap ;
1111use std:: sync:: Arc ;
12+ use std:: time:: Duration ;
1213
1314use driven_core:: orchestrator:: Orchestrator ;
1415use driven_core:: state:: StateRepo ;
1516use driven_core:: types:: AccountId ;
16- use tokio:: sync:: Mutex ;
17+ use tokio:: sync:: { watch , Mutex } ;
1718use tokio:: task:: JoinHandle ;
1819
1920/// How the per-account remote store was constructed at assembly time.
@@ -28,53 +29,148 @@ pub enum RemoteMode {
2829 Fake ,
2930}
3031
31- /// One account's live orchestrator: the control-surface handle plus the
32- /// `JoinHandle` of its spawned [`Orchestrator::run`] loop (SPEC s5).
32+ /// One account's live orchestrator: the control-surface handle plus EVERY
33+ /// per-account tokio task spawned by `assembly::build_account` (SPEC s5,
34+ /// ROADMAP M5 "no orphaned tokio tasks"; DESIGN s5.10.2 in-flight drain).
3335///
34- /// Held so IPC can drive the orchestrator (`trigger` / `set_paused` /
35- /// `state`) and so a clean shutdown can GRACEFULLY drain the run loop (ROADMAP
36- /// M5 "Quit cleanly shuts down the runtime, no orphaned tokio tasks";
37- /// DESIGN s5.10.2 in-flight drain).
36+ /// R-P1-1: a clean Quit must leave NO orphaned tasks. Four tasks are spawned
37+ /// per account and ALL are tracked here so [`Self::shutdown`] can drain them:
38+ /// - `run_loop`: [`Orchestrator::run`]. Stopped via `Orchestrator::shutdown()`.
39+ /// - `watcher_bridge`: forwards `NotifyWatcher` scan-ticks into the
40+ /// orchestrator. The watcher owns the `mpsc::Sender`, so its `recv().await`
41+ /// never closes on its own; it must be signalled via [`Self::bridge_shutdown`]
42+ /// (it `select!`s on that watch) or aborted.
43+ /// - `event_bridge`: forwards the orchestrator's `OrchestratorEvent` broadcast
44+ /// to the tray + webview. It ends naturally when the broadcast closes (the
45+ /// orchestrator dropped) but is ALSO signalled so quit does not have to wait
46+ /// on a `Lagged`/slow consumer; aborted on timeout.
47+ /// - `power_poller`: the `RealPowerSource` 30s poll loop. It loops forever (no
48+ /// natural end), so its handle is KEPT and ABORTED on shutdown - dropping it
49+ /// (the old bug) orphaned the task.
50+ ///
51+ /// Held so IPC can drive the orchestrator (`trigger` / `set_paused` / `state`)
52+ /// and so [`Self::shutdown`] can stop + join every task on quit.
3853pub struct AccountHandle {
3954 /// The per-account orchestrator control surface.
4055 pub orchestrator : Arc < dyn Orchestrator > ,
41- /// The spawned run-loop task. Behind a `Mutex<Option<..>>` so a clean
42- /// shutdown can TAKE + await it for a graceful drain (awaiting a
43- /// `JoinHandle` needs ownership, which the shared `&AppState` cannot give
44- /// otherwise). `None` once drained.
45- pub run_loop : Mutex < Option < JoinHandle < ( ) > > > ,
56+ /// The spawned run-loop task. Behind a `Mutex<Option<..>>` so the shutdown
57+ /// path can TAKE + await it by value; `None` once drained.
58+ run_loop : Mutex < Option < JoinHandle < ( ) > > > ,
59+ /// The watcher-bridge task (forwards scan-ticks), or `None` when no enabled
60+ /// source produced a watcher. Drained on shutdown.
61+ watcher_bridge : Mutex < Option < JoinHandle < ( ) > > > ,
62+ /// The orchestrator-event -> tray/IPC bridge task. Drained on shutdown.
63+ event_bridge : Mutex < Option < JoinHandle < ( ) > > > ,
64+ /// The power-source poller task. Looped forever; ABORTED on shutdown.
65+ power_poller : Mutex < Option < JoinHandle < ( ) > > > ,
66+ /// Shutdown signal the watcher + event bridges `select!` on (R-P1-1). Set to
67+ /// `true` by [`Self::shutdown`] so a bridge whose source never closes
68+ /// (the watcher owns its `Sender`) still exits promptly.
69+ bridge_shutdown : watch:: Sender < bool > ,
70+ }
71+
72+ /// The per-task graceful-drain budget on quit (DESIGN s5.10.2): await each task
73+ /// this long before aborting it. The run loop's own in-flight cycle is bounded
74+ /// by the larger `SHUTDOWN_DRAIN_TIMEOUT` in `lib.rs` (which calls
75+ /// [`AccountHandle::shutdown`] inside its own outer timeout); this per-task
76+ /// budget keeps a single wedged bridge from holding the join indefinitely.
77+ const TASK_DRAIN_TIMEOUT : Duration = Duration :: from_secs ( 5 ) ;
78+
79+ /// The collected per-account task handles + the bridge shutdown sender, returned
80+ /// by `assembly::build_account` and stored on [`AccountHandle`]. Groups the four
81+ /// tracked tasks so the constructor signature stays readable (R-P1-1).
82+ pub struct AccountTasks {
83+ /// [`Orchestrator::run`] loop.
84+ pub run_loop : JoinHandle < ( ) > ,
85+ /// Watcher -> orchestrator scan-tick bridge, or `None` if none was spawned.
86+ pub watcher_bridge : Option < JoinHandle < ( ) > > ,
87+ /// Orchestrator-event -> tray/IPC bridge.
88+ pub event_bridge : JoinHandle < ( ) > ,
89+ /// Power-source poll loop.
90+ pub power_poller : JoinHandle < ( ) > ,
91+ /// The sender the watcher + event bridges `select!` on for shutdown.
92+ pub bridge_shutdown : watch:: Sender < bool > ,
4693}
4794
4895impl AccountHandle {
49- /// Build a handle from the orchestrator control surface + its spawned run
50- /// loop .
96+ /// Build a handle from the orchestrator control surface + the collected
97+ /// per-account task set (R-P1-1) .
5198 #[ must_use]
52- pub fn new ( orchestrator : Arc < dyn Orchestrator > , run_loop : JoinHandle < ( ) > ) -> Self {
99+ pub fn new ( orchestrator : Arc < dyn Orchestrator > , tasks : AccountTasks ) -> Self {
53100 Self {
54101 orchestrator,
55- run_loop : Mutex :: new ( Some ( run_loop) ) ,
102+ run_loop : Mutex :: new ( Some ( tasks. run_loop ) ) ,
103+ watcher_bridge : Mutex :: new ( tasks. watcher_bridge ) ,
104+ event_bridge : Mutex :: new ( Some ( tasks. event_bridge ) ) ,
105+ power_poller : Mutex :: new ( Some ( tasks. power_poller ) ) ,
106+ bridge_shutdown : tasks. bridge_shutdown ,
56107 }
57108 }
58109
59- /// Await the run-loop task to completion (the graceful-drain path). Takes
60- /// the handle out so the wait happens by value; a second call (already
61- /// drained) returns immediately. Errors awaiting the task (cancelled /
62- /// panicked) are swallowed - the goal is "the task is no longer running".
63- pub async fn run_loop_drain ( & self ) {
64- let taken = self . run_loop . lock ( ) . await . take ( ) ;
65- if let Some ( handle) = taken {
66- let _ = handle. await ;
67- }
110+ /// Stop + JOIN every per-account task so quit leaves NO orphaned tokio task
111+ /// (R-P1-1, ROADMAP M5 acceptance; DESIGN s5.10.2 graceful drain).
112+ ///
113+ /// Order:
114+ /// 1. signal the orchestrator to stop after its in-flight cycle
115+ /// (`Orchestrator::shutdown()`), and signal the bridges via
116+ /// [`Self::bridge_shutdown`] (the watcher bridge's source never closes on
117+ /// its own);
118+ /// 2. for EVERY tracked handle: await-with-timeout, and on timeout abort
119+ /// the task AND AWAIT the aborted handle - so the task is truly GONE, not
120+ /// merely abort-requested. The power poller loops forever, so it always
121+ /// takes the abort path; the others normally drain cleanly.
122+ ///
123+ /// Idempotent: a second call finds every handle already taken and returns
124+ /// immediately. Errors awaiting a task (cancelled / panicked) are swallowed -
125+ /// the post-condition is "the task is no longer running".
126+ pub async fn shutdown ( & self ) {
127+ // 1) Signal stop. The orchestrator finishes its current cycle then
128+ // returns; the bridges observe the watch flip and select! out.
129+ self . orchestrator . shutdown ( ) ;
130+ // A send error only means there are no live bridge receivers (already
131+ // gone) - benign.
132+ let _ = self . bridge_shutdown . send ( true ) ;
133+
134+ // 2) Drain each tracked task: cleanly within the budget, else abort +
135+ // await so it cannot outlive quit.
136+ drain_or_abort ( & self . run_loop ) . await ;
137+ drain_or_abort ( & self . watcher_bridge ) . await ;
138+ drain_or_abort ( & self . event_bridge ) . await ;
139+ drain_or_abort ( & self . power_poller ) . await ;
68140 }
141+ }
69142
70- /// An [`tokio::task::AbortHandle`] for the run loop (the timeout-fallback
71- /// path), or `None` if it was already drained/taken.
72- pub async fn run_loop_abort_handle ( & self ) -> Option < tokio:: task:: AbortHandle > {
73- self . run_loop
74- . lock ( )
75- . await
76- . as_ref ( )
77- . map ( JoinHandle :: abort_handle)
143+ /// Take the handle out of `slot` and drive it to a true stop: await it up to
144+ /// [`TASK_DRAIN_TIMEOUT`]; on timeout `abort()` it and AWAIT the aborted handle
145+ /// so the task is genuinely finished before this returns (R-P1-1). A `None`
146+ /// slot (already drained / never spawned) is a no-op.
147+ ///
148+ /// `tokio::time::timeout` MOVES the `JoinHandle` into itself and, on elapse,
149+ /// DROPS it - and a dropped `JoinHandle` does NOT cancel its task (it merely
150+ /// detaches it). So we capture an [`tokio::task::AbortHandle`] BEFORE the
151+ /// timeout, and on elapse abort via it, then RE-AWAIT the same task via a second
152+ /// `JoinHandle` we also kept... which `timeout` consumed. To avoid that, we do
153+ /// not hand the original handle to `timeout`; we `select!` between the handle and
154+ /// a sleep so the handle stays in scope and can be re-awaited after an abort.
155+ async fn drain_or_abort ( slot : & Mutex < Option < JoinHandle < ( ) > > > ) {
156+ let Some ( mut handle) = slot. lock ( ) . await . take ( ) else {
157+ return ;
158+ } ;
159+ let abort = handle. abort_handle ( ) ;
160+ tokio:: select! {
161+ // Bias toward the task finishing: if it completes within the budget we
162+ // take this arm and never abort.
163+ biased;
164+ _join_result = & mut handle => {
165+ // Joined cleanly (or the task panicked - either way it is gone).
166+ }
167+ ( ) = tokio:: time:: sleep( TASK_DRAIN_TIMEOUT ) => {
168+ // Budget elapsed: request cancellation, then AWAIT the same handle
169+ // so the task is genuinely finished (a JoinError::cancelled is the
170+ // expected, swallowed result) before we return.
171+ abort. abort( ) ;
172+ let _ = handle. await ;
173+ }
78174 }
79175}
80176
@@ -163,3 +259,182 @@ impl AppState {
163259 self . remote_mode
164260 }
165261}
262+
263+ #[ cfg( test) ]
264+ mod tests {
265+ use super :: * ;
266+ use driven_core:: orchestrator:: { Orchestrator , OrchestratorConfig , TickSource } ;
267+ use driven_core:: types:: OrchestratorState ;
268+ use std:: sync:: atomic:: { AtomicBool , Ordering } ;
269+
270+ /// A no-op [`Orchestrator`] whose `run()` returns as soon as `shutdown()` is
271+ /// signalled - mirroring the real run loop's graceful-drain contract, so the
272+ /// R-P1-1 shutdown test exercises the clean-join path for the run loop while
273+ /// the poller (which loops forever) exercises the abort-and-await path.
274+ struct FakeOrchestrator {
275+ shutdown : watch:: Sender < bool > ,
276+ shutdown_rx : watch:: Receiver < bool > ,
277+ }
278+
279+ impl FakeOrchestrator {
280+ fn new ( ) -> Self {
281+ let ( shutdown, shutdown_rx) = watch:: channel ( false ) ;
282+ Self {
283+ shutdown,
284+ shutdown_rx,
285+ }
286+ }
287+ }
288+
289+ #[ async_trait:: async_trait]
290+ impl Orchestrator for FakeOrchestrator {
291+ async fn run ( & self ) -> anyhow:: Result < ( ) > {
292+ let mut rx = self . shutdown_rx . clone ( ) ;
293+ loop {
294+ if * rx. borrow ( ) {
295+ return Ok ( ( ) ) ;
296+ }
297+ if rx. changed ( ) . await . is_err ( ) {
298+ return Ok ( ( ) ) ;
299+ }
300+ }
301+ }
302+ async fn trigger ( & self , _reason : TickSource ) { }
303+ async fn set_paused ( & self , _paused : bool ) { }
304+ async fn state ( & self ) -> OrchestratorState {
305+ OrchestratorState :: Idle { last_run_at : None }
306+ }
307+ async fn reconfigure ( & self , _config : OrchestratorConfig ) { }
308+ fn shutdown ( & self ) {
309+ let _ = self . shutdown . send ( true ) ;
310+ }
311+ }
312+
313+ #[ tokio:: test]
314+ async fn shutdown_joins_every_per_account_task_no_orphans ( ) {
315+ // R-P1-1: `AccountHandle::shutdown` must leave ZERO orphaned tasks - the
316+ // run loop, watcher bridge, event bridge, AND the forever-looping power
317+ // poller must all be finished (joined or aborted-and-awaited) when it
318+ // returns. This is the M5 "no orphaned tokio tasks" acceptance, modelled
319+ // on the EXACT task shapes assembly spawns.
320+ let orchestrator: Arc < dyn Orchestrator > = Arc :: new ( FakeOrchestrator :: new ( ) ) ;
321+
322+ // The bridge shutdown signal both bridges select! on (the watcher bridge
323+ // never closes on its own; the poller loops forever).
324+ let ( bridge_shutdown, _rx0) = watch:: channel ( false ) ;
325+
326+ // Watcher bridge: an mpsc whose Sender we KEEP (modelling NotifyWatcher
327+ // owning the sender), so recv() never returns None on its own - the
328+ // bridge can only end via the shutdown signal.
329+ let ( _watch_tx, mut watch_rx) = tokio:: sync:: mpsc:: channel :: < u32 > ( 4 ) ;
330+ let watcher_bridge = {
331+ let mut shutdown = bridge_shutdown. subscribe ( ) ;
332+ tokio:: spawn ( async move {
333+ loop {
334+ tokio:: select! {
335+ _ = watch_rx. recv( ) => { }
336+ res = shutdown. changed( ) => {
337+ match res {
338+ Ok ( ( ) ) if * shutdown. borrow( ) => break ,
339+ Ok ( ( ) ) => { }
340+ Err ( _) => break ,
341+ }
342+ }
343+ }
344+ }
345+ } )
346+ } ;
347+
348+ // Event bridge: a broadcast whose Sender we KEEP, so recv() blocks
349+ // indefinitely - it can only end via the shutdown signal.
350+ let ( _evt_tx, mut evt_rx) = tokio:: sync:: broadcast:: channel :: < u32 > ( 4 ) ;
351+ let event_bridge = {
352+ let mut shutdown = bridge_shutdown. subscribe ( ) ;
353+ tokio:: spawn ( async move {
354+ loop {
355+ tokio:: select! {
356+ res = shutdown. changed( ) => {
357+ match res {
358+ Ok ( ( ) ) if * shutdown. borrow( ) => break ,
359+ Ok ( ( ) ) => { }
360+ Err ( _) => break ,
361+ }
362+ }
363+ _ = evt_rx. recv( ) => { }
364+ }
365+ }
366+ } )
367+ } ;
368+
369+ // Power poller: loops FOREVER with no shutdown path (exactly the real
370+ // `RealPowerSource::spawn_poller` shape) - only abort can stop it. A flag
371+ // proves it actually ran (and was not a no-op) before being aborted.
372+ let poller_ran = Arc :: new ( AtomicBool :: new ( false ) ) ;
373+ let power_poller = {
374+ let poller_ran = poller_ran. clone ( ) ;
375+ tokio:: spawn ( async move {
376+ poller_ran. store ( true , Ordering :: SeqCst ) ;
377+ let mut ticker = tokio:: time:: interval ( Duration :: from_millis ( 10 ) ) ;
378+ loop {
379+ ticker. tick ( ) . await ;
380+ }
381+ } )
382+ } ;
383+
384+ // Capture abort handles BEFORE moving the JoinHandles into the handle, so
385+ // the test can independently assert each task is finished afterwards.
386+ let run_loop = {
387+ let orch = orchestrator. clone ( ) ;
388+ tokio:: spawn ( async move {
389+ let _ = orch. run ( ) . await ;
390+ } )
391+ } ;
392+ let run_loop_abort = run_loop. abort_handle ( ) ;
393+ let watcher_abort = watcher_bridge. abort_handle ( ) ;
394+ let event_abort = event_bridge. abort_handle ( ) ;
395+ let poller_abort = power_poller. abort_handle ( ) ;
396+
397+ let handle = AccountHandle :: new (
398+ orchestrator,
399+ AccountTasks {
400+ run_loop,
401+ watcher_bridge : Some ( watcher_bridge) ,
402+ event_bridge,
403+ power_poller,
404+ bridge_shutdown,
405+ } ,
406+ ) ;
407+
408+ // Let the poller actually start before we shut down.
409+ tokio:: time:: sleep ( Duration :: from_millis ( 30 ) ) . await ;
410+
411+ // The whole shutdown must complete well within the per-task budgets (the
412+ // run loop + bridges drain cleanly; the poller is aborted). Bound it so a
413+ // regression (a task that never stops) fails instead of hanging.
414+ tokio:: time:: timeout ( Duration :: from_secs ( 10 ) , handle. shutdown ( ) )
415+ . await
416+ . expect ( "shutdown must complete (no task left orphaned)" ) ;
417+
418+ assert ! (
419+ poller_ran. load( Ordering :: SeqCst ) ,
420+ "the power poller task must have actually started"
421+ ) ;
422+
423+ // EVERY task is now finished (cleanly joined or aborted-and-awaited).
424+ assert ! ( run_loop_abort. is_finished( ) , "run loop must be finished" ) ;
425+ assert ! (
426+ watcher_abort. is_finished( ) ,
427+ "watcher bridge must be finished"
428+ ) ;
429+ assert ! ( event_abort. is_finished( ) , "event bridge must be finished" ) ;
430+ assert ! (
431+ poller_abort. is_finished( ) ,
432+ "power poller must be finished (aborted, not orphaned)"
433+ ) ;
434+
435+ // Idempotent: a second shutdown is a no-op and does not panic / hang.
436+ tokio:: time:: timeout ( Duration :: from_secs ( 2 ) , handle. shutdown ( ) )
437+ . await
438+ . expect ( "second shutdown is an immediate no-op" ) ;
439+ }
440+ }
0 commit comments