Skip to content

Commit a773668

Browse files
pmaxhoganclaude
andcommitted
fix(app): track + shut down all per-account tasks (zero orphans on quit) (R-P1-1)
Quit still leaked tokio tasks (M5 "no orphaned tasks" acceptance unmet). The graceful drain only handled AccountHandle.run_loop; the watcher bridge (its NotifyWatcher owns the mpsc::Sender so rx.recv().await never closed), the event bridge (spawned untracked), and the power poller (its JoinHandle was dropped immediately) all leaked. The timeout path also abort()d after the JoinHandle was moved into timeout (which drops, not cancels) without awaiting the aborted task. AccountHandle now stores ALL four per-account task handles (run_loop, watcher_bridge, event_bridge, power_poller) plus a bridge_shutdown watch::Sender. The watcher + event bridges tokio::select! on that signal so they exit even though their sources never close. AccountHandle::shutdown signals the orchestrator + bridges, then for EVERY handle does await-with-timeout and, on timeout, abort AND AWAIT the aborted handle so the task is truly gone (drain_or_abort keeps an AbortHandle and re-awaits via select! rather than letting timeout drop the JoinHandle). The power poller loops forever, so its handle is kept and aborted (not dropped). lib.rs shutdown_orchestrators now delegates to AccountHandle::shutdown under an outer SHUTDOWN_DRAIN_TIMEOUT. Test: shutdown_joins_every_per_account_task_no_orphans builds the exact task shapes assembly spawns and asserts all four are finished after shutdown(), and that a second shutdown is an immediate no-op. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CyiRqk2DVwmJjEu5gcD1m
1 parent 4aa2913 commit a773668

5 files changed

Lines changed: 448 additions & 86 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,8 @@ driven-power = { path = "../crates/driven-power" }
4040
# M5 assembly seams: real network breaker backend + Windows VSS provider.
4141
driven-net = { path = "../crates/driven-net" }
4242
driven-vss = { path = "../crates/driven-vss" }
43+
44+
[dev-dependencies]
45+
# R-P1-1 shutdown test: a no-op `Orchestrator` impl to build an `AccountHandle`
46+
# and assert every per-account task is joined after `shutdown()`.
47+
async-trait.workspace = true

src-tauri/src/app_state.rs

Lines changed: 308 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
1010
use std::collections::HashMap;
1111
use std::sync::Arc;
12+
use std::time::Duration;
1213

1314
use driven_core::orchestrator::Orchestrator;
1415
use driven_core::state::StateRepo;
1516
use driven_core::types::AccountId;
16-
use tokio::sync::Mutex;
17+
use tokio::sync::{watch, Mutex};
1718
use 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.
3853
pub 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

4895
impl 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

Comments
 (0)