Skip to main content

maolan_engine/
executor.rs

1//! Cycle executor for [`RenderPlan`] — Phase 2 of `LOCKLESS.md`.
2//!
3//! The executor replaces the legacy dynamic scheduler (`cycle_tasks`,
4//! `cycle_task_deps`, `finished`-flag sweeps in `engine/runtime.rs`) with
5//! count-up dependency counters over an immutable, atomically published plan:
6//!
7//! - `deps_completed[i]` is a plain (non-atomic) `u64` — only the dispatcher
8//!   thread mutates it. A node is ready when its counter reaches
9//!   `cycle * indegree[i]`; exactly one completion ever crosses the threshold.
10//! - Per cycle: `cycle += 1`, dispatch everything in `plan.sources`. No
11//!   counter reset, no memcpy, no scan, no allocation.
12//! - Per plan swap (rare, user-driven): the dispatcher pulls the published
13//!   plan at cycle start (`ArcSwap::load_full`), bumps `epoch`, and
14//!   re-baselines `deps_completed[i] = cycle * indegree[i]`. The old plan
15//!   always executes to completion; the swap takes effect at the next cycle
16//!   boundary. Stale completions from the previous plan are dropped via
17//!   `epoch`.
18//!
19//! Nodes whose dependencies can never be satisfied (`plan.forced`, feedback
20//! loops) are dispatched once the task timeout has elapsed since cycle start,
21//! mirroring the legacy `!progressed` fallback: they run with stale input
22//! data, which is the standard feedback-loop behaviour.
23
24use crate::message::{PluginKind, ProcessTask};
25use crate::render_plan::{NodeId, Op, PlanSlot, SharedPlan};
26use crate::state::TrackHandle;
27#[cfg(test)]
28use crate::track::Track;
29use std::sync::Arc;
30use std::sync::atomic::Ordering;
31use std::time::{Duration, Instant};
32
33/// A unit of work handed to a worker: execute `node` of `plan`.
34///
35/// The worker holds the plan `Arc` for the duration of the job, so the arena
36/// stays alive even if the plan is swapped out mid-cycle.
37#[derive(Clone)]
38pub struct NodeJob {
39    pub epoch: u64,
40    pub plan: SharedPlan,
41    pub node: NodeId,
42}
43
44impl std::fmt::Debug for NodeJob {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        f.debug_struct("NodeJob")
47            .field("epoch", &self.epoch)
48            .field("node", &self.node)
49            .finish()
50    }
51}
52
53/// Outcome of [`CycleExecutor::force_timeouts`] and
54/// [`CycleExecutor::abandon_node`].
55#[derive(Debug, Default)]
56pub struct ForceOutcome {
57    /// Newly dispatchable jobs (forced feedback nodes and dependents of
58    /// force-completed nodes).
59    pub jobs: Vec<NodeJob>,
60    /// Nodes that were force-completed with silence (timed out or abandoned).
61    pub silenced: Vec<NodeId>,
62    /// All nodes are done — the cycle is complete.
63    pub cycle_complete: bool,
64}
65
66/// The single-dispatcher cycle executor. Not `Sync` by design: all methods
67/// run on the engine dispatcher task.
68pub struct CycleExecutor {
69    slot: Arc<PlanSlot>,
70    plan: SharedPlan,
71    /// Monotonic cycle counter; a node of the current cycle is ready when
72    /// `deps_completed[i] == cycle * indegree[i]`.
73    cycle: u64,
74    /// Bumped on every plan swap; stale `NodeDone`s are dropped on mismatch.
75    epoch: u64,
76    deps_completed: Vec<u64>,
77    /// Cycle number at which each node was dispatched (0 = never).
78    dispatched: Vec<u64>,
79    /// Cycle number at which each node completed (0 = never).
80    completed: Vec<u64>,
81    /// Dispatch instant per node, for timeout detection.
82    started_at: Vec<Option<Instant>>,
83    /// Nodes not yet completed in the current cycle.
84    pending: usize,
85    cycle_started_at: Instant,
86    /// Forced (feedback) nodes were dispatched this cycle.
87    forced_dispatched: bool,
88}
89
90impl CycleExecutor {
91    pub fn new(slot: Arc<PlanSlot>) -> Self {
92        let plan = slot.load_full();
93        let now = Instant::now();
94        let n = plan.nodes.len();
95        Self {
96            slot,
97            plan,
98            cycle: 0,
99            epoch: 0,
100            deps_completed: vec![0; n],
101            dispatched: vec![0; n],
102            completed: vec![0; n],
103            started_at: vec![None; n],
104            pending: 0,
105            cycle_started_at: now,
106            forced_dispatched: false,
107        }
108    }
109
110    pub fn epoch(&self) -> u64 {
111        self.epoch
112    }
113
114    pub fn plan(&self) -> &SharedPlan {
115        &self.plan
116    }
117
118    pub fn cycle_complete(&self) -> bool {
119        self.pending == 0
120    }
121
122    /// Pull a newly published plan, if any. Only called at a cycle boundary,
123    /// so the old plan always executes to completion before the swap.
124    fn pull_plan(&mut self) {
125        let new = self.slot.load_full();
126        if Arc::ptr_eq(&new, &self.plan) {
127            return;
128        }
129        self.plan = new;
130        self.epoch = self.epoch.wrapping_add(1);
131        let n = self.plan.nodes.len();
132        // Re-baseline: after a fully completed cycle C, every counter equals
133        // C * indegree[i]; match that state for the new plan.
134        self.deps_completed.clear();
135        self.deps_completed
136            .extend(self.plan.indegree.iter().map(|&d| self.cycle * d as u64));
137        self.dispatched.clear();
138        self.dispatched.resize(n, 0);
139        self.completed.clear();
140        self.completed.resize(n, 0);
141        self.started_at.clear();
142        self.started_at.resize(n, None);
143    }
144
145    /// Start the next cycle and return the seed jobs (all source nodes).
146    pub fn start_cycle(&mut self, now: Instant) -> Vec<NodeJob> {
147        self.pull_plan();
148        self.cycle += 1;
149        self.pending = self.plan.nodes.len();
150        self.cycle_started_at = now;
151        self.forced_dispatched = false;
152        let cycle = self.cycle;
153        let sources = self.plan.sources.clone();
154        sources
155            .iter()
156            .map(|&node| self.dispatch(node, cycle, now))
157            .collect()
158    }
159
160    /// Record a worker completion. Stale-epoch completions are dropped.
161    /// Returns newly dispatchable jobs and whether the cycle is complete.
162    pub fn on_node_done(&mut self, epoch: u64, node: NodeId, now: Instant) -> (Vec<NodeJob>, bool) {
163        if epoch != self.epoch {
164            return (Vec::new(), self.cycle_complete());
165        }
166        if self.completed[node as usize] == self.cycle {
167            // Duplicate completion (e.g. a force-completed node's worker
168            // finally answered). Ignore: exactly-once is already satisfied.
169            return (Vec::new(), self.cycle_complete());
170        }
171        let jobs = self.complete_node(node, now);
172        let complete = self.cycle_complete();
173        (jobs, complete)
174    }
175
176    /// Force-complete timed-out nodes (silence their outputs) and, once the
177    /// task timeout has elapsed since cycle start, dispatch the forced
178    /// feedback nodes. Call this on every dispatcher tick and completion.
179    pub fn force_timeouts(&mut self, now: Instant, timeout: Duration) -> ForceOutcome {
180        let mut outcome = ForceOutcome::default();
181        if self.cycle_complete() {
182            return outcome;
183        }
184        let cycle = self.cycle;
185        let mut timed_out = Vec::new();
186        for node in 0..self.plan.nodes.len() as NodeId {
187            let idx = node as usize;
188            if self.dispatched[idx] != cycle || self.completed[idx] == cycle {
189                continue;
190            }
191            let Some(started) = self.started_at[idx] else {
192                continue;
193            };
194            if now.duration_since(started) >= timeout {
195                timed_out.push(node);
196            }
197        }
198        for node in timed_out {
199            self.silence_node(node);
200            outcome.silenced.push(node);
201            outcome.jobs.extend(self.complete_node(node, now));
202        }
203        if !self.forced_dispatched && now.duration_since(self.cycle_started_at) >= timeout {
204            self.forced_dispatched = true;
205            for &node in &self.plan.forced.clone() {
206                if self.dispatched[node as usize] != cycle {
207                    outcome.jobs.push(self.dispatch(node, cycle, now));
208                }
209            }
210        }
211        outcome.cycle_complete = self.cycle_complete();
212        outcome
213    }
214
215    /// A dispatch failed (worker channel closed): complete the node with
216    /// silence immediately so the cycle cannot stall.
217    pub fn abandon_node(&mut self, node: NodeId, now: Instant) -> ForceOutcome {
218        let mut outcome = ForceOutcome::default();
219        if self.completed[node as usize] == self.cycle {
220            outcome.cycle_complete = self.cycle_complete();
221            return outcome;
222        }
223        self.silence_node(node);
224        outcome.silenced.push(node);
225        outcome.jobs = self.complete_node(node, now);
226        outcome.cycle_complete = self.cycle_complete();
227        outcome
228    }
229
230    /// Mark a node dispatched and build its job.
231    fn dispatch(&mut self, node: NodeId, cycle: u64, now: Instant) -> NodeJob {
232        let idx = node as usize;
233        self.dispatched[idx] = cycle;
234        self.started_at[idx] = Some(now);
235        NodeJob {
236            epoch: self.epoch,
237            plan: self.plan.clone(),
238            node,
239        }
240    }
241
242    /// Mark a node complete and cascade counters into its dependents,
243    /// dispatching each dependent whose threshold was just crossed.
244    fn complete_node(&mut self, node: NodeId, now: Instant) -> Vec<NodeJob> {
245        let idx = node as usize;
246        self.completed[idx] = self.cycle;
247        self.started_at[idx] = None;
248        self.pending = self.pending.saturating_sub(1);
249        let cycle = self.cycle;
250        let mut jobs = Vec::new();
251        let dependents = self.plan.dependents[idx].clone();
252        for dep in dependents {
253            let dep_idx = dep as usize;
254            self.deps_completed[dep_idx] += 1;
255            let threshold = cycle * self.plan.indegree[dep_idx] as u64;
256            if self.dispatched[dep_idx] != cycle && self.deps_completed[dep_idx] == threshold {
257                jobs.push(self.dispatch(dep, cycle, now));
258            }
259        }
260        jobs
261    }
262
263    /// Write silence into the node's arena output buffers and, for task
264    /// nodes, set the port finished flags so any remaining legacy readiness
265    /// checks see a completed producer.
266    fn silence_node(&mut self, node: NodeId) {
267        let op = &self.plan.nodes[node as usize];
268        let (outs, task) = match op {
269            Op::Zero { output } => (vec![*output], None),
270            Op::Sum { output, .. } => (vec![*output], None),
271            Op::HwInput { output, .. } => (vec![*output], None),
272            Op::Task { task, outs, .. } => (outs.clone(), Some(task)),
273        };
274        for buf in outs {
275            // Safety: the executor force-completes the node's writer chain;
276            // dependents have not been dispatched yet, so no concurrent
277            // access to these buffers exists.
278            unsafe { &mut *self.plan.buffer_ptr(buf) }.fill(0.0);
279        }
280        let Some(task) = task else {
281            return;
282        };
283        let track = match task {
284            ProcessTask::Track(t) | ProcessTask::FolderInput(t) | ProcessTask::FolderOutput(t) => {
285                t.clone()
286            }
287            ProcessTask::Plugin { track, .. } => track.clone(),
288        };
289        silence_task_ports(&track, task);
290    }
291}
292
293/// Mirror of the legacy `force_stalled_task_completions` state handling:
294/// mark the task's output ports finished and clear track processing state.
295fn silence_task_ports(track: &TrackHandle, task: &ProcessTask) {
296    let t = track.lock();
297    match task {
298        ProcessTask::Track(_) | ProcessTask::FolderOutput(_) => t.audio.outs.clone(),
299        ProcessTask::FolderInput(_) => Vec::new(),
300        ProcessTask::Plugin { kind, index, .. } => match kind {
301            PluginKind::Clap => t
302                .clap_plugins
303                .get(*index)
304                .map(|p| p.processor.audio_outputs().to_vec())
305                .unwrap_or_default(),
306            PluginKind::Vst3 => t
307                .vst3_plugins
308                .get(*index)
309                .map(|p| p.processor.audio_outputs().to_vec())
310                .unwrap_or_default(),
311            #[cfg(all(unix, not(target_os = "macos")))]
312            PluginKind::Lv2 => t
313                .lv2_plugins
314                .get(*index)
315                .map(|p| p.processor.audio_outputs().to_vec())
316                .unwrap_or_default(),
317        },
318    }
319    .iter()
320    .for_each(|out| {
321        out.finished.store(true, Ordering::Release);
322    });
323    t.audio.set_processing(false);
324    t.audio.set_finished(true);
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::render_plan::RenderPlan;
331    use std::cell::UnsafeCell;
332    use std::collections::HashMap;
333    use std::sync::Mutex;
334
335    struct TestPlanSlot {
336        collector: Option<basedrop::Collector>,
337        slot: Option<Arc<PlanSlot>>,
338    }
339
340    impl TestPlanSlot {
341        fn new(plan: RenderPlan) -> Self {
342            let collector = basedrop::Collector::new();
343            let owned = basedrop::Owned::new(&collector.handle(), plan);
344            Self {
345                collector: Some(collector),
346                slot: Some(Arc::new(PlanSlot::from_pointee(owned))),
347            }
348        }
349
350        fn slot(&self) -> Arc<PlanSlot> {
351            self.slot.as_ref().expect("test slot").clone()
352        }
353
354        fn store(&self, plan: RenderPlan) {
355            let owned = basedrop::Owned::new(
356                &self.collector.as_ref().expect("test collector").handle(),
357                plan,
358            );
359            self.slot
360                .as_ref()
361                .expect("test slot")
362                .store(Arc::new(owned));
363        }
364    }
365
366    impl Drop for TestPlanSlot {
367        fn drop(&mut self) {
368            self.slot.take();
369            let Some(mut collector) = self.collector.take() else {
370                return;
371            };
372            collector.collect();
373            let _ = collector.try_cleanup();
374        }
375    }
376
377    fn slot_with(plan: RenderPlan) -> TestPlanSlot {
378        TestPlanSlot::new(plan)
379    }
380
381    /// Chain plan: two source tasks -> sum -> sink task.
382    /// Buffers: 0 = task A out, 1 = task B out, 2 = sink in, 3 = sink out.
383    fn chain_plan(track: &TrackHandle) -> RenderPlan {
384        let nodes = vec![
385            Op::Task {
386                task: ProcessTask::Track(track.clone()),
387                ins: vec![],
388                outs: vec![0],
389            },
390            Op::Task {
391                task: ProcessTask::Track(track.clone()),
392                ins: vec![],
393                outs: vec![1],
394            },
395            Op::Sum {
396                inputs: vec![0, 1],
397                delays: vec![
398                    UnsafeCell::new(crate::render_plan::DelayLine::new()),
399                    UnsafeCell::new(crate::render_plan::DelayLine::new()),
400                ],
401                output: 2,
402            },
403            Op::Task {
404                task: ProcessTask::Track(track.clone()),
405                ins: vec![2],
406                outs: vec![3],
407            },
408        ];
409        RenderPlan {
410            buffer_size: 8,
411            buffers: (0..4).map(|_| UnsafeCell::new(vec![0.0; 8])).collect(),
412            buffer_latencies: (0..4)
413                .map(|_| std::sync::atomic::AtomicUsize::new(0))
414                .collect(),
415            nodes,
416            indegree: vec![0, 0, 2, 1],
417            dependents: vec![vec![2], vec![2], vec![3], vec![]],
418            sources: vec![0, 1],
419            hw_in_map: vec![],
420            hw_out_map: vec![],
421            port_map: HashMap::new(),
422            midi_edges: vec![],
423            forced: vec![],
424        }
425    }
426
427    fn make_track(name: &str) -> TrackHandle {
428        Arc::new(Track::new(name.to_string(), 1, 1, 0, 0, 8, 48_000.0))
429    }
430
431    #[test]
432    fn counters_dispatch_in_dependency_order_exactly_once() {
433        let track = make_track("t");
434        let slot_guard = slot_with(chain_plan(&track));
435        let slot = slot_guard.slot();
436        let mut exec = CycleExecutor::new(slot);
437        let now = Instant::now();
438
439        let jobs = exec.start_cycle(now);
440        assert_eq!(jobs.len(), 2, "two source tasks");
441        let mut seen: Vec<NodeId> = jobs.iter().map(|j| j.node).collect();
442        seen.sort_unstable();
443        assert_eq!(seen, vec![0, 1]);
444
445        // Complete source 0: the sum needs both, so nothing new.
446        let (jobs, complete) = exec.on_node_done(exec.epoch(), 0, now);
447        assert!(jobs.is_empty() && !complete);
448
449        // Complete source 1: threshold for the sum is crossed exactly once.
450        let (jobs, complete) = exec.on_node_done(exec.epoch(), 1, now);
451        assert_eq!(jobs.len(), 1);
452        assert_eq!(jobs[0].node, 2);
453        assert!(!complete);
454        // A duplicate completion of source 1 must not re-dispatch.
455        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
456        assert!(jobs.is_empty());
457
458        let (jobs, complete) = exec.on_node_done(exec.epoch(), 2, now);
459        assert_eq!(jobs.len(), 1);
460        assert_eq!(jobs[0].node, 3);
461        assert!(!complete);
462
463        let (jobs, complete) = exec.on_node_done(exec.epoch(), 3, now);
464        assert!(jobs.is_empty());
465        assert!(complete, "cycle complete after the sink");
466
467        // Next cycle re-dispatches sources without any counter reset.
468        let jobs = exec.start_cycle(now);
469        assert_eq!(jobs.len(), 2);
470        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
471        assert!(jobs.is_empty());
472        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
473        assert_eq!(jobs.len(), 1, "cycle 2 counters re-baselined correctly");
474    }
475
476    #[test]
477    fn swap_during_cycle_keeps_old_plan_until_boundary() {
478        let track = make_track("t");
479        let slot_guard = slot_with(chain_plan(&track));
480        let slot = slot_guard.slot();
481        let mut exec = CycleExecutor::new(slot.clone());
482        let now = Instant::now();
483
484        let jobs = exec.start_cycle(now);
485        assert_eq!(jobs.len(), 2);
486        let epoch0 = exec.epoch();
487
488        // Swap in a fresh plan mid-cycle.
489        slot_guard.store(chain_plan(&track));
490
491        // Old-epoch completions still count; the swap is invisible mid-cycle.
492        let (jobs, _) = exec.on_node_done(epoch0, 0, now);
493        assert!(jobs.is_empty());
494        let (jobs, _) = exec.on_node_done(epoch0, 1, now);
495        assert_eq!(jobs.len(), 1, "old plan still executes to completion");
496        assert_eq!(exec.epoch(), epoch0, "no swap before the boundary");
497
498        // A completion stamped with a *future* epoch is dropped.
499        let (jobs, _) = exec.on_node_done(epoch0 + 1, 2, now);
500        assert!(jobs.is_empty());
501
502        let (jobs, _) = exec.on_node_done(epoch0, 2, now);
503        assert_eq!(jobs.len(), 1);
504        let (_, complete) = exec.on_node_done(epoch0, 3, now);
505        assert!(complete);
506
507        // The boundary pulls the new plan and re-baselines.
508        let jobs = exec.start_cycle(now);
509        assert_eq!(exec.epoch(), epoch0 + 1);
510        assert_eq!(jobs.len(), 2);
511        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
512        assert!(jobs.is_empty());
513        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
514        assert_eq!(jobs.len(), 1, "new plan runs after the boundary");
515    }
516
517    /// The spec's swap test: a simulated parallel cycle (two worker threads)
518    /// runs while the plan is swapped; every node must execute exactly once
519    /// and the cycle must complete without tearing.
520    #[test]
521    fn swap_during_simulated_parallel_cycle_exactly_once() {
522        let track = make_track("t");
523        let slot_guard = slot_with(chain_plan(&track));
524        let slot = slot_guard.slot();
525        let exec = Mutex::new(CycleExecutor::new(slot.clone()));
526        let now = Instant::now();
527
528        let jobs = exec.lock().expect("lock").start_cycle(now);
529        let epoch0 = exec.lock().expect("lock").epoch();
530        // Swap immediately: the parallel cycle runs entirely on the old plan.
531        slot_guard.store(chain_plan(&track));
532
533        // A single shared queue behind a Mutex; two threads pull jobs and
534        // report done through the executor, exactly as the tokio workers do.
535        let queue: Arc<Mutex<std::collections::VecDeque<NodeJob>>> =
536            Arc::new(Mutex::new(jobs.into_iter().collect()));
537        let executed: Arc<Mutex<Vec<NodeId>>> = Arc::new(Mutex::new(Vec::new()));
538        std::thread::scope(|s| {
539            for _ in 0..2 {
540                let queue = queue.clone();
541                let executed = executed.clone();
542                let exec = &exec;
543                s.spawn(move || {
544                    loop {
545                        let job = queue.lock().expect("lock").pop_front();
546                        let Some(job) = job else {
547                            break;
548                        };
549                        // "Execute": write the node index into its first output
550                        // buffer, like a real task writing audio.
551                        if let Op::Task { outs, .. } = &job.plan.nodes[job.node as usize]
552                            && let Some(&buf) = outs.first()
553                        {
554                            // Safety: this worker executes this node; the plan
555                            // invariant guarantees no concurrent access.
556                            let out = unsafe { &mut *job.plan.buffer_ptr(buf) };
557                            out[0] = job.node as f32;
558                        }
559                        executed.lock().expect("lock").push(job.node);
560                        std::thread::yield_now();
561                        let (new_jobs, _) = exec
562                            .lock()
563                            .expect("lock")
564                            .on_node_done(epoch0, job.node, now);
565                        queue.lock().expect("lock").extend(new_jobs);
566                    }
567                });
568            }
569        });
570
571        let mut counts = [0usize; 4];
572        for node in executed.lock().expect("lock").iter() {
573            counts[*node as usize] += 1;
574        }
575        assert_eq!(
576            counts,
577            [1, 1, 1, 1],
578            "every node executed exactly once across two racing workers"
579        );
580        assert!(exec.lock().expect("lock").cycle_complete());
581    }
582
583    #[test]
584    fn timeout_silences_node_outputs_and_completes_by_index() {
585        let track = make_track("t");
586        let slot_guard = slot_with(chain_plan(&track));
587        let slot = slot_guard.slot();
588        let mut exec = CycleExecutor::new(slot);
589        let now = Instant::now();
590        let timeout = Duration::from_millis(250);
591
592        let jobs = exec.start_cycle(now);
593        assert_eq!(jobs.len(), 2);
594        // Dirty the output buffers of both source tasks via the plan.
595        // Safety: test thread, nothing else is running.
596        unsafe {
597            (&mut *exec.plan().buffer_ptr(0)).fill(1.0);
598            (&mut *exec.plan().buffer_ptr(1)).fill(2.0);
599        }
600
601        // Complete nothing; advance past the timeout.
602        let later = now + timeout + Duration::from_millis(1);
603        let outcome = exec.force_timeouts(later, timeout);
604        assert_eq!(outcome.silenced, vec![0, 1], "both sources timed out");
605        // Their outputs were silenced by index.
606        unsafe {
607            assert!(exec.plan().buffer(0).iter().all(|&s| s == 0.0));
608            assert!(exec.plan().buffer(1).iter().all(|&s| s == 0.0));
609        }
610        // The sum became dispatchable (both dependencies force-completed).
611        assert_eq!(outcome.jobs.len(), 1);
612        assert_eq!(outcome.jobs[0].node, 2);
613        assert!(!outcome.cycle_complete);
614
615        // The track's legacy port flags were set so dependent bodies proceed.
616        let t = track.lock();
617        assert!(t.audio.finished());
618        assert!(!t.audio.processing());
619        for out in &t.audio.outs {
620            assert!(out.finished.load(Ordering::Acquire));
621        }
622        // Finish the cycle; a second timeout pass is a no-op.
623        let (jobs, _) = exec.on_node_done(exec.epoch(), 2, later);
624        assert_eq!(jobs.len(), 1);
625        let outcome = exec.force_timeouts(later, timeout);
626        assert!(outcome.silenced.is_empty());
627        let (_, complete) = exec.on_node_done(exec.epoch(), 3, later);
628        assert!(complete);
629    }
630
631    #[test]
632    fn abandon_node_completes_with_silence() {
633        let track = make_track("t");
634        let slot_guard = slot_with(chain_plan(&track));
635        let slot = slot_guard.slot();
636        let mut exec = CycleExecutor::new(slot);
637        let now = Instant::now();
638
639        let jobs = exec.start_cycle(now);
640        assert_eq!(jobs.len(), 2);
641        unsafe { (&mut *exec.plan().buffer_ptr(0)).fill(3.0) };
642
643        let outcome = exec.abandon_node(0, now);
644        assert_eq!(outcome.silenced, vec![0]);
645        unsafe {
646            assert!(exec.plan().buffer(0).iter().all(|&s| s == 0.0));
647        }
648        // Abandoning twice is a no-op.
649        let outcome = exec.abandon_node(0, now);
650        assert!(outcome.silenced.is_empty());
651        assert!(outcome.jobs.is_empty());
652
653        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
654        assert_eq!(jobs.len(), 1, "abandon + done crosses the sum threshold");
655    }
656
657    #[test]
658    fn forced_feedback_nodes_dispatch_after_timeout() {
659        let track = make_track("t");
660        let mut plan = chain_plan(&track);
661        // Turn the sink into an undispatchable feedback node.
662        plan.indegree[3] = 2;
663        plan.forced = vec![3];
664        let slot_guard = slot_with(plan);
665        let slot = slot_guard.slot();
666        let mut exec = CycleExecutor::new(slot);
667        let now = Instant::now();
668        let timeout = Duration::from_millis(250);
669
670        let jobs = exec.start_cycle(now);
671        assert_eq!(jobs.len(), 2);
672        // Run the acyclic part; the sink can never reach its threshold.
673        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
674        assert!(jobs.is_empty());
675        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
676        assert_eq!(jobs.len(), 1);
677        let (jobs, complete) = exec.on_node_done(exec.epoch(), 2, now);
678        assert!(jobs.is_empty() && !complete);
679
680        // Before the timeout: still stuck.
681        let outcome = exec.force_timeouts(now + Duration::from_millis(10), timeout);
682        assert!(outcome.jobs.is_empty());
683        // After the timeout: the forced node is dispatched.
684        let outcome = exec.force_timeouts(now + timeout + Duration::from_millis(1), timeout);
685        assert_eq!(outcome.jobs.len(), 1);
686        assert_eq!(outcome.jobs[0].node, 3);
687
688        let (_, complete) = exec.on_node_done(exec.epoch(), 3, now);
689        assert!(complete);
690    }
691
692    #[test]
693    fn stale_epoch_completions_are_dropped() {
694        let track = make_track("t");
695        let slot_guard = slot_with(chain_plan(&track));
696        let slot = slot_guard.slot();
697        let mut exec = CycleExecutor::new(slot);
698        let now = Instant::now();
699
700        let jobs = exec.start_cycle(now);
701        assert_eq!(jobs.len(), 2);
702        let (jobs, _) = exec.on_node_done(exec.epoch() + 7, 0, now);
703        assert!(jobs.is_empty());
704        let (jobs, _) = exec.on_node_done(exec.epoch().wrapping_sub(1), 1, now);
705        assert!(jobs.is_empty());
706        // Real completions still work afterwards.
707        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
708        assert!(jobs.is_empty());
709        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
710        assert_eq!(jobs.len(), 1);
711    }
712}