dfir_rs/scheduled/context.rs
1//! Module for the inline DFIR runtime context and execution engine.
2//!
3//! Provides [`Context`] (the lightweight operator context) and
4//! [`Dfir`] (the dataflow execution wrapper).
5
6use std::future::Future;
7use std::pin::Pin;
8use std::rc::Rc;
9use std::sync::Arc;
10use std::sync::atomic::Ordering;
11use std::task::Wake;
12
13#[cfg(feature = "meta")]
14use dfir_lang::diagnostic::{Diagnostic, Diagnostics, SerdeSpan};
15#[cfg(feature = "meta")]
16use dfir_lang::graph::DfirGraph;
17
18use super::metrics::{DfirMetrics, DfirMetricsIntervals};
19use crate::scheduled::ticks::TickInstant;
20
21/// Coordinates waking between [`Context`] (inside the tick closure) and [`Dfir`]
22/// (the external runner). Shared via `Arc` between both.
23///
24/// When external data arrives (e.g., a tokio stream receives a message), the [`Context::waker`]
25/// fires, which sets `can_start_tick` and wakes the [`Dfir::run`](Dfir::run) task so it starts a new tick.
26/// Implements [`Wake`] directly so it can be used as a `Waker` without an extra wrapper.
27#[doc(hidden)]
28pub struct WakeState {
29 /// Set to `true` when external data arrives, signaling that a new tick should run.
30 /// Checked by [`Dfir::run_tick`](Dfir::run_tick) and [`Dfir::run_available`](Dfir::run_available).
31 can_start_tick: std::sync::atomic::AtomicBool,
32 /// Wakes the [`Dfir::run`](Dfir::run) task from its idle `poll_fn` sleep.
33 task_waker: futures::task::AtomicWaker,
34}
35
36impl Default for WakeState {
37 fn default() -> Self {
38 Self {
39 can_start_tick: std::sync::atomic::AtomicBool::new(false),
40 task_waker: futures::task::AtomicWaker::new(),
41 }
42 }
43}
44
45impl Wake for WakeState {
46 fn wake(self: Arc<Self>) {
47 self.wake_by_ref();
48 }
49
50 fn wake_by_ref(self: &Arc<Self>) {
51 self.can_start_tick.store(true, Ordering::Relaxed);
52 self.task_waker.wake();
53 }
54}
55
56/// A lightweight context for inline codegen that avoids the overhead of the full
57/// scheduled graph (no tokio channels, no scheduler queues, no loop machinery).
58///
59/// Exposes methods that operator-generated code calls on both
60/// `df` (for prologues: `request_task`) and
61/// `context` (for iterators: `current_tick`, `schedule_subgraph`, etc.).
62#[derive(Default)]
63pub struct Context {
64 /// Counter for number of ticks run.
65 current_tick: TickInstant,
66 /// Coordinates waking between [`Context`] (inside the tick closure) and [`Dfir`]
67 /// (the external runner). Shared via `Arc` between both. Implements [`Wake`].
68 wake_state: Arc<WakeState>,
69 /// Live-updating DFIR runtime metrics via interior mutability.
70 metrics: Rc<DfirMetrics>,
71 /// Tasks buffered via [`Self::request_task`], spawned by [`Dfir::spawn_tasks`]
72 /// once the runtime is running inside a tokio `LocalSet`.
73 #[cfg(feature = "tokio")]
74 tasks_to_spawn: Vec<Pin<Box<dyn Future<Output = ()> + 'static>>>,
75}
76
77impl Context {
78 /// Create a new inline context with shared wake state and metrics.
79 pub fn new(wake_state: Arc<WakeState>, metrics: Rc<DfirMetrics>) -> Self {
80 Self {
81 current_tick: TickInstant::default(),
82 wake_state,
83 metrics,
84 #[cfg(feature = "tokio")]
85 tasks_to_spawn: Vec::new(),
86 }
87 }
88
89 // --- Methods called as `df.xxx()` in operator prologues ---
90
91 /// Buffers an async task to be spawned later by `Dfir::spawn_tasks`.
92 ///
93 /// Tasks are deferred because `write_prologue` runs during graph construction,
94 /// which may occur before a tokio `LocalSet` is entered. Buffered tasks are
95 /// drained and spawned via `tokio::task::spawn_local` at the start of
96 /// [`Dfir::run_tick`]. Tasks requested after that point remain buffered until
97 /// the next call to [`Dfir::run_tick`].
98 #[cfg(feature = "tokio")]
99 pub fn request_task<Fut>(&mut self, future: Fut)
100 where
101 Fut: Future<Output = ()> + 'static,
102 {
103 self.tasks_to_spawn.push(Box::pin(future));
104 }
105
106 // --- Methods called as `context.xxx()` in operator iterators ---
107
108 /// Gets the current tick count.
109 pub fn current_tick(&self) -> TickInstant {
110 self.current_tick
111 }
112
113 /// Returns a reference to the runtime metrics.
114 pub fn metrics(&self) -> &Rc<DfirMetrics> {
115 &self.metrics
116 }
117
118 /// Signals that external data has arrived and a new tick should be started.
119 pub fn schedule_subgraph(&self, is_external: bool) {
120 if is_external {
121 self.wake_state.wake_by_ref();
122 }
123 }
124
125 /// Returns a waker that signals external data has arrived.
126 pub fn waker(&self) -> std::task::Waker {
127 std::task::Waker::from(self.wake_state.clone())
128 }
129
130 /// Increments the tick counter.
131 /// Called by the generated tick closure at the end of each tick.
132 #[doc(hidden)]
133 pub fn __end_tick(&mut self) {
134 self.current_tick += crate::scheduled::ticks::TickDuration::SINGLE_TICK;
135 }
136}
137
138/// An executable DFIR dataflow, as created by
139/// [`dfir_syntax!`](crate::dfir_syntax). Provides the [`Self::run`],
140/// [`Self::run_available`], and [`Self::run_tick`] family of methods to
141/// execute the dataflow.
142///
143/// # Design
144///
145/// The inline codegen generates an `async move |df: &mut Context|` closure that captures
146/// dataflow-specific state (handoff buffers, source iterators) and receives the [`Context`]
147/// (tick counter, metrics) by reference each tick. `Dfir` owns both the
148/// closure and the context, and coordinates tick lifecycle and idle/wake behavior.
149///
150/// We use a single opaque closure rather than generating a bespoke struct per dataflow because:
151/// - The closure naturally captures exactly the state it needs with correct lifetimes
152/// - No codegen needed for struct definitions, field accessors, or initialization
153/// - Rust's async closure machinery handles the complex state machine (suspend/resume across
154/// `.await` points) that would be very difficult to replicate in a generated struct
155///
156/// The `Tick` type parameter is bounded by [`TickClosure`] (not `AsyncFnMut` directly) to
157/// support type erasure via [`TickClosureErased`] / [`DfirErased`] for heterogeneous
158/// collections (e.g., the sim runtime storing multiple locations in a `Vec`). The concrete
159/// (non-erased) path used by trybuild and embedded has zero overhead.
160pub struct Dfir<Tick> {
161 /// Async closure which runs a single tick when called.
162 tick_closure: Tick,
163 /// Coordinates waking between [`Context`] (inside the tick closure) and [`Dfir`]
164 /// (the external runner). Shared via `Arc` between both. Implements [`Wake`].
165 wake_state: Arc<WakeState>,
166 /// The inline context, owned by `Dfir` and passed to the tick closure by reference.
167 context: Context,
168 /// See [`Self::meta_graph()`].
169 #[cfg(feature = "meta")]
170 meta_graph: Option<DfirGraph>,
171 /// See [`Self::diagnostics()`].
172 #[cfg(feature = "meta")]
173 diagnostics: Option<Vec<Diagnostic<SerdeSpan>>>,
174}
175
176/// Trait for tick closures — abstracts over both concrete async closures
177/// and type-erased boxed versions ([`TickClosureErased`]).
178///
179/// The `&mut Context` parameter is owned by [`Dfir`] and lent to the
180/// closure each tick, avoiding shared-ownership overhead for the context.
181#[doc(hidden)]
182pub trait TickClosure {
183 /// Call the tick closure. Returns `true` if any subgraph received input data.
184 fn call_tick<'a>(&'a mut self, ctx: &'a mut Context) -> impl Future<Output = bool> + 'a;
185}
186
187impl<F: for<'a> AsyncFnMut(&'a mut Context) -> bool> TickClosure for F {
188 fn call_tick<'a>(&'a mut self, ctx: &'a mut Context) -> impl Future<Output = bool> + 'a {
189 self(ctx)
190 }
191}
192
193/// No-op `TickClosure`.
194#[doc(hidden)]
195pub struct NullTickClosure;
196
197impl TickClosure for NullTickClosure {
198 fn call_tick<'a>(&'a mut self, _ctx: &'a mut Context) -> impl Future<Output = bool> + 'a {
199 std::future::ready(false)
200 }
201}
202
203/// Type-erased tick function for use in heterogeneous collections (e.g., the sim runtime).
204#[doc(hidden)]
205pub struct TickClosureErased(Box<dyn TickClosureErasedInner>);
206
207/// Object-safe inner trait for [`TickClosureErased`]. Needed because `AsyncFnMut` is not
208/// object-safe (GAT return type), but a trait with `&mut self -> Pin<Box<dyn Future + '_>>`
209/// is — the returned future borrows from the trait object which owns the closure.
210trait TickClosureErasedInner {
211 fn call_tick<'a>(
212 &'a mut self,
213 ctx: &'a mut Context,
214 ) -> Pin<Box<dyn Future<Output = bool> + 'a>>;
215}
216
217impl<F: for<'a> AsyncFnMut(&'a mut Context) -> bool> TickClosureErasedInner for F {
218 fn call_tick<'a>(
219 &'a mut self,
220 ctx: &'a mut Context,
221 ) -> Pin<Box<dyn Future<Output = bool> + 'a>> {
222 Box::pin(self(ctx))
223 }
224}
225
226impl TickClosure for TickClosureErased {
227 fn call_tick<'a>(&'a mut self, ctx: &'a mut Context) -> impl Future<Output = bool> + 'a {
228 self.0.call_tick(ctx)
229 }
230}
231
232/// Type alias for a type-erased [`Dfir`] that can be stored in heterogeneous collections.
233/// Created via [`Dfir::into_erased`].
234pub type DfirErased = Dfir<TickClosureErased>;
235
236impl<Tick: TickClosure> Dfir<Tick> {
237 /// Create a new `Dfir` from a tick closure, inline context,
238 /// and meta graph / diagnostics JSON strings.
239 #[doc(hidden)]
240 pub fn new(
241 tick_closure: Tick,
242 context: Context,
243 meta_graph_json: Option<&str>,
244 diagnostics_json: Option<&str>,
245 ) -> Self {
246 #[cfg(not(feature = "meta"))]
247 let _ = (meta_graph_json, diagnostics_json);
248 Self {
249 tick_closure,
250 wake_state: context.wake_state.clone(),
251 context,
252 #[cfg(feature = "meta")]
253 meta_graph: meta_graph_json.map(|json| {
254 let mut meta_graph: DfirGraph =
255 serde_json::from_str(json).expect("Failed to deserialize graph.");
256 let mut op_inst_diagnostics = Diagnostics::new();
257 meta_graph.insert_node_op_insts_all(&mut op_inst_diagnostics);
258 assert!(
259 op_inst_diagnostics.is_empty(),
260 "Expected no diagnostics, got: {:#?}",
261 op_inst_diagnostics
262 );
263 meta_graph
264 }),
265 #[cfg(feature = "meta")]
266 diagnostics: diagnostics_json.map(|json| {
267 serde_json::from_str(json).expect("Failed to deserialize diagnostics.")
268 }),
269 }
270 }
271
272 /// Return a handle to the meta graph, if set.
273 #[cfg(feature = "meta")]
274 #[cfg_attr(docsrs, doc(cfg(feature = "meta")))]
275 pub fn meta_graph(&self) -> Option<&DfirGraph> {
276 self.meta_graph.as_ref()
277 }
278
279 /// Returns any diagnostics generated by the surface syntax macro.
280 #[cfg(feature = "meta")]
281 #[cfg_attr(docsrs, doc(cfg(feature = "meta")))]
282 pub fn diagnostics(&self) -> Option<&[Diagnostic<SerdeSpan>]> {
283 self.diagnostics.as_deref()
284 }
285
286 /// Returns a reference-counted handle to the continually-updated runtime metrics for this DFIR instance.
287 pub fn metrics(&self) -> Rc<DfirMetrics> {
288 Rc::clone(self.context.metrics())
289 }
290
291 /// Gets the current tick (local time) count.
292 pub fn current_tick(&self) -> TickInstant {
293 self.context.current_tick()
294 }
295
296 /// Returns a [`DfirMetricsIntervals`] handle where each call to
297 /// [`DfirMetricsIntervals::take_interval`] ends the current interval and returns its metrics.
298 ///
299 /// The first call to `take_interval` returns metrics since this DFIR instance was created. Each subsequent call to
300 /// `take_interval` returns metrics since the previous call.
301 ///
302 /// Cloning the handle "forks" it from the original, as afterwards each interval may return different metrics
303 /// depending on when exactly `take_interval` is called.
304 pub fn metrics_intervals(&self) -> DfirMetricsIntervals {
305 DfirMetricsIntervals {
306 curr: self.metrics(),
307 prev: None,
308 }
309 }
310}
311
312impl<Tick: TickClosure> Dfir<Tick> {
313 /// Spawns all tasks buffered via [`Context::request_task`].
314 ///
315 /// This drains the buffer, so subsequent calls are no-ops until new tasks are requested.
316 #[cfg(feature = "tokio")]
317 fn spawn_tasks(&mut self) {
318 for task in self.context.tasks_to_spawn.drain(..) {
319 tokio::task::spawn_local(task);
320 }
321 }
322
323 /// Run a single tick. Returns `true` if any subgraph received input data.
324 ///
325 /// Checks both handoff buffers (via `work_done` flag set in generated recv port code)
326 /// and external events (via `can_start_tick` set by wakers/schedule_subgraph).
327 pub async fn run_tick(&mut self) -> bool {
328 #[cfg(feature = "tokio")]
329 self.spawn_tasks();
330 let had_external = self
331 .wake_state
332 .can_start_tick
333 .swap(false, Ordering::Relaxed);
334 let tick_had_work = self.tick_closure.call_tick(&mut self.context).await;
335 had_external || tick_had_work || self.wake_state.can_start_tick.load(Ordering::Relaxed)
336 }
337
338 /// Run a single tick synchronously. Panics if the tick yields (async suspension).
339 /// Returns `true` if work was done (see [`Self::run_tick`]).
340 pub fn run_tick_sync(&mut self) -> bool {
341 let mut fut = std::pin::pin!(self.run_tick());
342 let mut ctx = std::task::Context::from_waker(std::task::Waker::noop());
343 match fut.as_mut().poll(&mut ctx) {
344 std::task::Poll::Ready(result) => result,
345 std::task::Poll::Pending => {
346 panic!("Dfir::run_tick_sync: tick yielded asynchronously.")
347 }
348 }
349 }
350
351 /// Run ticks as long as work is available, then return.
352 #[cfg(feature = "tokio")]
353 pub async fn run_available(&mut self) {
354 // Always run at least one tick.
355 self.wake_state
356 .can_start_tick
357 .store(false, Ordering::Relaxed);
358 loop {
359 self.run_tick().await;
360 let can_start_tick = self
361 .wake_state
362 .can_start_tick
363 .swap(false, Ordering::Relaxed);
364 if !can_start_tick {
365 break;
366 }
367 // Yield between each tick to receive more events.
368 tokio::task::yield_now().await;
369 }
370 }
371
372 /// [`Self::run_available`] but panics if any tick yields asynchronously.
373 pub fn run_available_sync(&mut self) {
374 self.wake_state
375 .can_start_tick
376 .store(false, Ordering::Relaxed);
377 loop {
378 self.run_tick_sync();
379 let can_start_tick = self
380 .wake_state
381 .can_start_tick
382 .swap(false, Ordering::Relaxed);
383 if !can_start_tick {
384 break;
385 }
386 }
387 }
388
389 /// Run forever, processing ticks when work is available and yielding when idle.
390 #[cfg(feature = "tokio")]
391 pub async fn run(&mut self) -> crate::Never {
392 loop {
393 self.run_available().await;
394 // Wait for an external event to wake us.
395 std::future::poll_fn(|cx| {
396 // Register waker first to avoid race: if an event fires between
397 // the check and the register, the waker is already in place.
398 self.wake_state.task_waker.register(cx.waker());
399 if self.wake_state.can_start_tick.load(Ordering::Relaxed) {
400 std::task::Poll::Ready(())
401 } else {
402 std::task::Poll::Pending
403 }
404 })
405 .await;
406 }
407 }
408}
409
410impl<Tick: 'static + for<'a> AsyncFnMut(&'a mut Context) -> bool> Dfir<Tick> {
411 /// Type-erase the tick closure for use in heterogeneous collections.
412 ///
413 /// Wraps the concrete async closure in [`TickClosureErased`], which boxes the future
414 /// returned by each tick call. This adds one heap allocation per tick, but enables
415 /// storing multiple `Dfir`s with different closure types in a single `Vec`.
416 ///
417 /// Only needed for the sim runtime path. The trybuild and embedded paths keep the
418 /// concrete type and pay no erasure cost.
419 pub fn into_erased(self) -> DfirErased {
420 Dfir {
421 tick_closure: TickClosureErased(Box::new(self.tick_closure)),
422 wake_state: self.wake_state,
423 context: self.context,
424 #[cfg(feature = "meta")]
425 meta_graph: self.meta_graph,
426 #[cfg(feature = "meta")]
427 diagnostics: self.diagnostics,
428 }
429 }
430}