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                output: 2,
398            },
399            Op::Task {
400                task: ProcessTask::Track(track.clone()),
401                ins: vec![2],
402                outs: vec![3],
403            },
404        ];
405        RenderPlan {
406            buffer_size: 8,
407            buffers: (0..4).map(|_| UnsafeCell::new(vec![0.0; 8])).collect(),
408            nodes,
409            indegree: vec![0, 0, 2, 1],
410            dependents: vec![vec![2], vec![2], vec![3], vec![]],
411            sources: vec![0, 1],
412            hw_in_map: vec![],
413            hw_out_map: vec![],
414            port_map: HashMap::new(),
415            midi_edges: vec![],
416            forced: vec![],
417        }
418    }
419
420    fn make_track(name: &str) -> TrackHandle {
421        Arc::new(Track::new(name.to_string(), 1, 1, 0, 0, 8, 48_000.0))
422    }
423
424    #[test]
425    fn counters_dispatch_in_dependency_order_exactly_once() {
426        let track = make_track("t");
427        let slot_guard = slot_with(chain_plan(&track));
428        let slot = slot_guard.slot();
429        let mut exec = CycleExecutor::new(slot);
430        let now = Instant::now();
431
432        let jobs = exec.start_cycle(now);
433        assert_eq!(jobs.len(), 2, "two source tasks");
434        let mut seen: Vec<NodeId> = jobs.iter().map(|j| j.node).collect();
435        seen.sort_unstable();
436        assert_eq!(seen, vec![0, 1]);
437
438        // Complete source 0: the sum needs both, so nothing new.
439        let (jobs, complete) = exec.on_node_done(exec.epoch(), 0, now);
440        assert!(jobs.is_empty() && !complete);
441
442        // Complete source 1: threshold for the sum is crossed exactly once.
443        let (jobs, complete) = exec.on_node_done(exec.epoch(), 1, now);
444        assert_eq!(jobs.len(), 1);
445        assert_eq!(jobs[0].node, 2);
446        assert!(!complete);
447        // A duplicate completion of source 1 must not re-dispatch.
448        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
449        assert!(jobs.is_empty());
450
451        let (jobs, complete) = exec.on_node_done(exec.epoch(), 2, now);
452        assert_eq!(jobs.len(), 1);
453        assert_eq!(jobs[0].node, 3);
454        assert!(!complete);
455
456        let (jobs, complete) = exec.on_node_done(exec.epoch(), 3, now);
457        assert!(jobs.is_empty());
458        assert!(complete, "cycle complete after the sink");
459
460        // Next cycle re-dispatches sources without any counter reset.
461        let jobs = exec.start_cycle(now);
462        assert_eq!(jobs.len(), 2);
463        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
464        assert!(jobs.is_empty());
465        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
466        assert_eq!(jobs.len(), 1, "cycle 2 counters re-baselined correctly");
467    }
468
469    #[test]
470    fn swap_during_cycle_keeps_old_plan_until_boundary() {
471        let track = make_track("t");
472        let slot_guard = slot_with(chain_plan(&track));
473        let slot = slot_guard.slot();
474        let mut exec = CycleExecutor::new(slot.clone());
475        let now = Instant::now();
476
477        let jobs = exec.start_cycle(now);
478        assert_eq!(jobs.len(), 2);
479        let epoch0 = exec.epoch();
480
481        // Swap in a fresh plan mid-cycle.
482        slot_guard.store(chain_plan(&track));
483
484        // Old-epoch completions still count; the swap is invisible mid-cycle.
485        let (jobs, _) = exec.on_node_done(epoch0, 0, now);
486        assert!(jobs.is_empty());
487        let (jobs, _) = exec.on_node_done(epoch0, 1, now);
488        assert_eq!(jobs.len(), 1, "old plan still executes to completion");
489        assert_eq!(exec.epoch(), epoch0, "no swap before the boundary");
490
491        // A completion stamped with a *future* epoch is dropped.
492        let (jobs, _) = exec.on_node_done(epoch0 + 1, 2, now);
493        assert!(jobs.is_empty());
494
495        let (jobs, _) = exec.on_node_done(epoch0, 2, now);
496        assert_eq!(jobs.len(), 1);
497        let (_, complete) = exec.on_node_done(epoch0, 3, now);
498        assert!(complete);
499
500        // The boundary pulls the new plan and re-baselines.
501        let jobs = exec.start_cycle(now);
502        assert_eq!(exec.epoch(), epoch0 + 1);
503        assert_eq!(jobs.len(), 2);
504        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
505        assert!(jobs.is_empty());
506        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
507        assert_eq!(jobs.len(), 1, "new plan runs after the boundary");
508    }
509
510    /// The spec's swap test: a simulated parallel cycle (two worker threads)
511    /// runs while the plan is swapped; every node must execute exactly once
512    /// and the cycle must complete without tearing.
513    #[test]
514    fn swap_during_simulated_parallel_cycle_exactly_once() {
515        let track = make_track("t");
516        let slot_guard = slot_with(chain_plan(&track));
517        let slot = slot_guard.slot();
518        let exec = Mutex::new(CycleExecutor::new(slot.clone()));
519        let now = Instant::now();
520
521        let jobs = exec.lock().expect("lock").start_cycle(now);
522        let epoch0 = exec.lock().expect("lock").epoch();
523        // Swap immediately: the parallel cycle runs entirely on the old plan.
524        slot_guard.store(chain_plan(&track));
525
526        // A single shared queue behind a Mutex; two threads pull jobs and
527        // report done through the executor, exactly as the tokio workers do.
528        let queue: Arc<Mutex<std::collections::VecDeque<NodeJob>>> =
529            Arc::new(Mutex::new(jobs.into_iter().collect()));
530        let executed: Arc<Mutex<Vec<NodeId>>> = Arc::new(Mutex::new(Vec::new()));
531        std::thread::scope(|s| {
532            for _ in 0..2 {
533                let queue = queue.clone();
534                let executed = executed.clone();
535                let exec = &exec;
536                s.spawn(move || {
537                    loop {
538                        let job = queue.lock().expect("lock").pop_front();
539                        let Some(job) = job else {
540                            break;
541                        };
542                        // "Execute": write the node index into its first output
543                        // buffer, like a real task writing audio.
544                        if let Op::Task { outs, .. } = &job.plan.nodes[job.node as usize]
545                            && let Some(&buf) = outs.first()
546                        {
547                            // Safety: this worker executes this node; the plan
548                            // invariant guarantees no concurrent access.
549                            let out = unsafe { &mut *job.plan.buffer_ptr(buf) };
550                            out[0] = job.node as f32;
551                        }
552                        executed.lock().expect("lock").push(job.node);
553                        std::thread::yield_now();
554                        let (new_jobs, _) = exec
555                            .lock()
556                            .expect("lock")
557                            .on_node_done(epoch0, job.node, now);
558                        queue.lock().expect("lock").extend(new_jobs);
559                    }
560                });
561            }
562        });
563
564        let mut counts = [0usize; 4];
565        for node in executed.lock().expect("lock").iter() {
566            counts[*node as usize] += 1;
567        }
568        assert_eq!(
569            counts,
570            [1, 1, 1, 1],
571            "every node executed exactly once across two racing workers"
572        );
573        assert!(exec.lock().expect("lock").cycle_complete());
574    }
575
576    #[test]
577    fn timeout_silences_node_outputs_and_completes_by_index() {
578        let track = make_track("t");
579        let slot_guard = slot_with(chain_plan(&track));
580        let slot = slot_guard.slot();
581        let mut exec = CycleExecutor::new(slot);
582        let now = Instant::now();
583        let timeout = Duration::from_millis(250);
584
585        let jobs = exec.start_cycle(now);
586        assert_eq!(jobs.len(), 2);
587        // Dirty the output buffers of both source tasks via the plan.
588        // Safety: test thread, nothing else is running.
589        unsafe {
590            (&mut *exec.plan().buffer_ptr(0)).fill(1.0);
591            (&mut *exec.plan().buffer_ptr(1)).fill(2.0);
592        }
593
594        // Complete nothing; advance past the timeout.
595        let later = now + timeout + Duration::from_millis(1);
596        let outcome = exec.force_timeouts(later, timeout);
597        assert_eq!(outcome.silenced, vec![0, 1], "both sources timed out");
598        // Their outputs were silenced by index.
599        unsafe {
600            assert!(exec.plan().buffer(0).iter().all(|&s| s == 0.0));
601            assert!(exec.plan().buffer(1).iter().all(|&s| s == 0.0));
602        }
603        // The sum became dispatchable (both dependencies force-completed).
604        assert_eq!(outcome.jobs.len(), 1);
605        assert_eq!(outcome.jobs[0].node, 2);
606        assert!(!outcome.cycle_complete);
607
608        // The track's legacy port flags were set so dependent bodies proceed.
609        let t = track.lock();
610        assert!(t.audio.finished());
611        assert!(!t.audio.processing());
612        for out in &t.audio.outs {
613            assert!(out.finished.load(Ordering::Acquire));
614        }
615        // Finish the cycle; a second timeout pass is a no-op.
616        let (jobs, _) = exec.on_node_done(exec.epoch(), 2, later);
617        assert_eq!(jobs.len(), 1);
618        let outcome = exec.force_timeouts(later, timeout);
619        assert!(outcome.silenced.is_empty());
620        let (_, complete) = exec.on_node_done(exec.epoch(), 3, later);
621        assert!(complete);
622    }
623
624    #[test]
625    fn abandon_node_completes_with_silence() {
626        let track = make_track("t");
627        let slot_guard = slot_with(chain_plan(&track));
628        let slot = slot_guard.slot();
629        let mut exec = CycleExecutor::new(slot);
630        let now = Instant::now();
631
632        let jobs = exec.start_cycle(now);
633        assert_eq!(jobs.len(), 2);
634        unsafe { (&mut *exec.plan().buffer_ptr(0)).fill(3.0) };
635
636        let outcome = exec.abandon_node(0, now);
637        assert_eq!(outcome.silenced, vec![0]);
638        unsafe {
639            assert!(exec.plan().buffer(0).iter().all(|&s| s == 0.0));
640        }
641        // Abandoning twice is a no-op.
642        let outcome = exec.abandon_node(0, now);
643        assert!(outcome.silenced.is_empty());
644        assert!(outcome.jobs.is_empty());
645
646        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
647        assert_eq!(jobs.len(), 1, "abandon + done crosses the sum threshold");
648    }
649
650    #[test]
651    fn forced_feedback_nodes_dispatch_after_timeout() {
652        let track = make_track("t");
653        let mut plan = chain_plan(&track);
654        // Turn the sink into an undispatchable feedback node.
655        plan.indegree[3] = 2;
656        plan.forced = vec![3];
657        let slot_guard = slot_with(plan);
658        let slot = slot_guard.slot();
659        let mut exec = CycleExecutor::new(slot);
660        let now = Instant::now();
661        let timeout = Duration::from_millis(250);
662
663        let jobs = exec.start_cycle(now);
664        assert_eq!(jobs.len(), 2);
665        // Run the acyclic part; the sink can never reach its threshold.
666        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
667        assert!(jobs.is_empty());
668        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
669        assert_eq!(jobs.len(), 1);
670        let (jobs, complete) = exec.on_node_done(exec.epoch(), 2, now);
671        assert!(jobs.is_empty() && !complete);
672
673        // Before the timeout: still stuck.
674        let outcome = exec.force_timeouts(now + Duration::from_millis(10), timeout);
675        assert!(outcome.jobs.is_empty());
676        // After the timeout: the forced node is dispatched.
677        let outcome = exec.force_timeouts(now + timeout + Duration::from_millis(1), timeout);
678        assert_eq!(outcome.jobs.len(), 1);
679        assert_eq!(outcome.jobs[0].node, 3);
680
681        let (_, complete) = exec.on_node_done(exec.epoch(), 3, now);
682        assert!(complete);
683    }
684
685    #[test]
686    fn stale_epoch_completions_are_dropped() {
687        let track = make_track("t");
688        let slot_guard = slot_with(chain_plan(&track));
689        let slot = slot_guard.slot();
690        let mut exec = CycleExecutor::new(slot);
691        let now = Instant::now();
692
693        let jobs = exec.start_cycle(now);
694        assert_eq!(jobs.len(), 2);
695        let (jobs, _) = exec.on_node_done(exec.epoch() + 7, 0, now);
696        assert!(jobs.is_empty());
697        let (jobs, _) = exec.on_node_done(exec.epoch().wrapping_sub(1), 1, now);
698        assert!(jobs.is_empty());
699        // Real completions still work afterwards.
700        let (jobs, _) = exec.on_node_done(exec.epoch(), 0, now);
701        assert!(jobs.is_empty());
702        let (jobs, _) = exec.on_node_done(exec.epoch(), 1, now);
703        assert_eq!(jobs.len(), 1);
704    }
705}