Skip to main content

shape_vm/executor/
task_scheduler.rs

1//! Task scheduler for the async host runtime.
2//!
3//! Manages spawned async tasks: stores their callables, tracks completion,
4//! and executes them (synchronously for now) when the VM suspends on an await.
5//!
6//! The initial design runs tasks inline (synchronous execution at await-time).
7//! True concurrent execution via Tokio can be layered on later by changing
8//! `resolve_task` to spawn on the Tokio runtime.
9//!
10//! ## Wave 6.5 R-async-time / E-async surface follow-up
11//!
12//! The pre-bulldozer scheduler stored callables and results as `ValueWord`
13//! and exposed an `executor_fn: FnOnce(ValueWord) -> Result<ValueWord, _>`
14//! callback for inline execution. `ValueWord` is deleted per ADR-006 §2.7
15//! / CLAUDE.md "Forbidden Patterns"; the post-§2.7.7 carrier shape is the
16//! `(bits: u64, kind: NativeKind)` pair (the same shape `pop_kinded()` /
17//! `push_kinded(...)` thread through the typed VM stack — see playbook
18//! §3 canonical pattern). This file's API now takes and returns kinded
19//! pairs end-to-end:
20//!
21//! - `register(task_id, callable_bits, callable_kind)`
22//! - `take_callable(task_id) -> Option<(u64, NativeKind)>`
23//! - `complete(task_id, result_bits, result_kind)`
24//! - `register_external(task_id) -> oneshot::Sender<Result<(u64, NativeKind), String>>`
25//! - `resolve_task<F>(task_id, executor_fn)` where
26//!   `F: FnOnce((u64, NativeKind)) -> Result<(u64, NativeKind), VMError>`
27//!
28//! Refcount discipline (playbook §3 drop discipline): every share stored
29//! in the scheduler's maps owns one strong-count for heap-bearing kinds.
30//! `take_callable`, `take_external_receiver`, `try_resolve_external`, and
31//! `Drop` transfer that share to the caller (or release it). `register`,
32//! `complete`, and the cached-result paths in `resolve_task` /
33//! `resolve_task_group` use `clone_with_kind` when handing the share to
34//! a second consumer.
35//!
36//! Out-of-territory callers (`call_convention.rs::resolve_spawned_task`,
37//! `async_ops/mod.rs::op_await` / `op_spawn_task` / `op_join_await`,
38//! `gc_integration.rs::scan_roots`) still reference deleted ValueWord-shape
39//! APIs; their migration is owned by separate sub-clusters and is out of
40//! R-async-time scope per playbook §10 dispatch protocol.
41
42use std::collections::HashMap;
43use std::sync::Arc;
44
45use shape_value::heap_value::{HeapKind, TaskGroupData};
46use shape_value::{NativeKind, VMError};
47
48use crate::executor::vm_impl::stack::{clone_with_kind, drop_with_kind};
49
50/// A kinded value held by the scheduler (post-§2.7.7 carrier shape).
51///
52/// `bits` is the raw 8-byte slot payload; `kind` is the parallel-track
53/// `NativeKind` interpretation. The pair owns one strong-count share for
54/// heap-bearing kinds; producing a copy bumps it via `clone_with_kind`,
55/// dropping it releases via `drop_with_kind`.
56type Kinded = (u64, NativeKind);
57
58/// Completion status of a spawned task.
59#[derive(Debug, Clone)]
60pub enum TaskStatus {
61    /// Task has been spawned but not yet executed.
62    Pending,
63    /// Task finished successfully with a result value (kinded pair).
64    Completed(Kinded),
65    /// Task was cancelled before completion.
66    Cancelled,
67}
68
69/// Scheduler that tracks spawned async tasks by their future ID.
70///
71/// The VM's `SpawnTask` opcode registers a callable here. When the VM later
72/// suspends on `WaitType::Future { id }`, the host looks up the callable,
73/// executes it, and stores the result so the VM can resume.
74///
75/// Supports both inline tasks (callable executed synchronously at await-time)
76/// and external tasks (completed by background Tokio tasks via oneshot channels).
77pub struct TaskScheduler {
78    /// Map from task_id to the callable kinded pair (Closure or Function bits
79    /// plus its `NativeKind`) that was passed to `spawn`. Consumed on first
80    /// execution.
81    callables: HashMap<u64, Kinded>,
82
83    /// Map from task_id to its completion status.
84    results: HashMap<u64, TaskStatus>,
85
86    /// External completion channels — Tokio background tasks send results here.
87    /// Used for remote calls and other externally-completed futures.
88    /// Result is a kinded pair on success.
89    external_receivers: HashMap<u64, tokio::sync::oneshot::Receiver<Result<Kinded, String>>>,
90}
91
92impl TaskScheduler {
93    /// Create a new, empty scheduler.
94    pub fn new() -> Self {
95        Self {
96            callables: HashMap::new(),
97            results: HashMap::new(),
98            external_receivers: HashMap::new(),
99        }
100    }
101
102    /// Register a callable for a given task_id.
103    ///
104    /// Called by `op_spawn_task` when a new task is spawned. The caller
105    /// transfers one strong-count share for the kinded pair into the
106    /// scheduler; on `take_callable` (or `Drop`) the share transfers back
107    /// out (or is released).
108    pub fn register(&mut self, task_id: u64, callable_bits: u64, callable_kind: NativeKind) {
109        // Replace any prior callable to preserve refcount discipline.
110        if let Some((old_bits, old_kind)) = self.callables.remove(&task_id) {
111            drop_with_kind(old_bits, old_kind);
112        }
113        self.callables
114            .insert(task_id, (callable_bits, callable_kind));
115        self.results.insert(task_id, TaskStatus::Pending);
116    }
117
118    /// Take (remove) the callable for `task_id` so it can be executed.
119    ///
120    /// Returns `None` if the task was already consumed or never registered.
121    /// Ownership of the kinded pair transfers to the caller.
122    pub fn take_callable(&mut self, task_id: u64) -> Option<Kinded> {
123        self.callables.remove(&task_id)
124    }
125
126    /// Record a completed result for a task.
127    ///
128    /// The caller transfers one strong-count share into the scheduler. If
129    /// a completion was already recorded, the prior share is released.
130    pub fn complete(&mut self, task_id: u64, value_bits: u64, value_kind: NativeKind) {
131        // Releasing a prior result preserves refcount discipline if a task
132        // is somehow completed twice (defensive — should not normally happen).
133        if let Some(TaskStatus::Completed((old_bits, old_kind))) =
134            self.results.insert(task_id, TaskStatus::Completed((value_bits, value_kind)))
135        {
136            drop_with_kind(old_bits, old_kind);
137        }
138    }
139
140    /// Mark a task as cancelled.
141    pub fn cancel(&mut self, task_id: u64) {
142        // Only cancel if still pending
143        if let Some(TaskStatus::Pending) = self.results.get(&task_id) {
144            self.results.insert(task_id, TaskStatus::Cancelled);
145            // Release the callable's share if still present.
146            if let Some((bits, kind)) = self.callables.remove(&task_id) {
147                drop_with_kind(bits, kind);
148            }
149        }
150    }
151
152    /// Get the result for a task, if it has completed.
153    pub fn get_result(&self, task_id: u64) -> Option<&TaskStatus> {
154        self.results.get(&task_id)
155    }
156
157    /// Check whether a task has a stored result (completed or cancelled).
158    pub fn is_resolved(&self, task_id: u64) -> bool {
159        matches!(
160            self.results.get(&task_id),
161            Some(TaskStatus::Completed(_)) | Some(TaskStatus::Cancelled)
162        )
163    }
164
165    /// Register an externally-completed task (e.g., remote call).
166    ///
167    /// Returns a `oneshot::Sender` that the background task uses to deliver the
168    /// result (kinded pair). The scheduler marks the task as Pending and
169    /// stores the receiver.
170    pub fn register_external(
171        &mut self,
172        task_id: u64,
173    ) -> tokio::sync::oneshot::Sender<Result<Kinded, String>> {
174        let (tx, rx) = tokio::sync::oneshot::channel();
175        self.results.insert(task_id, TaskStatus::Pending);
176        self.external_receivers.insert(task_id, rx);
177        tx
178    }
179
180    /// Try to resolve an external task (non-blocking check).
181    ///
182    /// Returns `Some(Ok((bits, kind)))` if the external task completed
183    /// successfully, `Some(Err(..))` on error/cancellation, or `None` if
184    /// still pending.
185    ///
186    /// On the cached-completion fast path, the cached share is cloned
187    /// (`clone_with_kind`) so both the scheduler entry and the returned
188    /// pair own independent shares — caller drops/uses freely.
189    pub fn try_resolve_external(&mut self, task_id: u64) -> Option<Result<Kinded, VMError>> {
190        if let Some(TaskStatus::Completed((bits, kind))) = self.results.get(&task_id).cloned() {
191            // Hand out a fresh share — the cached entry retains its own.
192            clone_with_kind(bits, kind);
193            return Some(Ok((bits, kind)));
194        }
195        if let Some(rx) = self.external_receivers.get_mut(&task_id) {
196            match rx.try_recv() {
197                Ok(Ok((bits, kind))) => {
198                    // The result share transferred from the background task.
199                    // Cache one share (clone) and hand out the original.
200                    clone_with_kind(bits, kind);
201                    self.results
202                        .insert(task_id, TaskStatus::Completed((bits, kind)));
203                    self.external_receivers.remove(&task_id);
204                    Some(Ok((bits, kind)))
205                }
206                Ok(Err(e)) => {
207                    self.external_receivers.remove(&task_id);
208                    Some(Err(VMError::RuntimeError(e)))
209                }
210                Err(tokio::sync::oneshot::error::TryRecvError::Empty) => None,
211                Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
212                    self.external_receivers.remove(&task_id);
213                    Some(Err(VMError::RuntimeError(
214                        "Remote task cancelled".to_string(),
215                    )))
216                }
217            }
218        } else {
219            None
220        }
221    }
222
223    /// Check whether a task has an external receiver (is externally-completed).
224    pub fn has_external(&self, task_id: u64) -> bool {
225        self.external_receivers.contains_key(&task_id)
226    }
227
228    /// Take the external receiver for async awaiting.
229    ///
230    /// Used by `execute_with_async` when it needs to truly `.await` an external
231    /// task's completion.
232    pub fn take_external_receiver(
233        &mut self,
234        task_id: u64,
235    ) -> Option<tokio::sync::oneshot::Receiver<Result<Kinded, String>>> {
236        self.external_receivers.remove(&task_id)
237    }
238
239    /// Resolve a single task by executing its callable on a fresh VM executor.
240    ///
241    /// This is the synchronous (inline) strategy: the callable is executed
242    /// immediately when awaited. Returns the result kinded pair, or an error.
243    ///
244    /// The `executor_fn` callback receives the callable kinded pair and must
245    /// execute it, returning the result kinded pair. Ownership of the pair
246    /// transfers into the callback; the callback's returned pair owns one
247    /// share which is then cached and a clone returned to the caller.
248    pub fn resolve_task<F>(&mut self, task_id: u64, executor_fn: F) -> Result<Kinded, VMError>
249    where
250        F: FnOnce(Kinded) -> Result<Kinded, VMError>,
251    {
252        // If already resolved, hand out a clone of the cached share.
253        if let Some(TaskStatus::Completed((bits, kind))) = self.results.get(&task_id).cloned() {
254            clone_with_kind(bits, kind);
255            return Ok((bits, kind));
256        }
257        if let Some(TaskStatus::Cancelled) = self.results.get(&task_id) {
258            return Err(VMError::RuntimeError(format!(
259                "Task {} was cancelled",
260                task_id
261            )));
262        }
263
264        // Take the callable (consume it — share transfers to executor_fn).
265        let callable = self.take_callable(task_id).ok_or_else(|| {
266            VMError::RuntimeError(format!("No callable registered for task {}", task_id))
267        })?;
268
269        // Execute synchronously — share transfers in, share transfers out.
270        let (bits, kind) = executor_fn(callable)?;
271
272        // Cache a clone of the result; hand the original share back.
273        clone_with_kind(bits, kind);
274        self.results
275            .insert(task_id, TaskStatus::Completed((bits, kind)));
276        Ok((bits, kind))
277    }
278
279    /// Resolve a task group according to the join strategy.
280    ///
281    /// Join kinds (encoded in the high 2 bits of JoinInit's packed operand):
282    ///   0 = All  — wait for all tasks, return array of results
283    ///   1 = Race — return first completed result
284    ///   2 = Any  — return first successful result (skip errors)
285    ///   3 = AllSettled — return array of {status, value/error} for every task
286    ///
287    /// Since we execute synchronously, "race" and "any" still run all tasks
288    /// sequentially but return early on the first applicable result.
289    ///
290    /// Returned aggregate is a `TaskGroup`-shaped heap value (Arc<TaskGroupData>)
291    /// holding the constituent task ids; the caller pushes the kinded pair
292    /// onto the stack with `NativeKind::Ptr(HeapKind::TaskGroup)`. (The
293    /// pre-bulldozer code returned a heap array of kinded results; without
294    /// a kinded VMArray helper post-§2.7.4, the TaskGroup carrier is the
295    /// minimum shape the await-time decoder can re-walk.)
296    pub fn resolve_task_group<F>(
297        &mut self,
298        kind: u8,
299        task_ids: &[u64],
300        mut executor_fn: F,
301    ) -> Result<Kinded, VMError>
302    where
303        F: FnMut(Kinded) -> Result<Kinded, VMError>,
304    {
305        match kind {
306            // All: collect all results — drop each child share since the
307            // aggregate carrier (TaskGroup) holds only ids, not values.
308            0 => {
309                for &id in task_ids {
310                    let (bits, k) = self.resolve_task(id, &mut executor_fn)?;
311                    drop_with_kind(bits, k);
312                }
313                let bits = Arc::into_raw(Arc::new(TaskGroupData {
314                    kind: 0,
315                    task_ids: task_ids.to_vec(),
316                })) as u64;
317                Ok((bits, NativeKind::Ptr(HeapKind::TaskGroup)))
318            }
319            // Race: return first result (all run, but we return first).
320            1 => {
321                for &id in task_ids {
322                    let res = self.resolve_task(id, &mut executor_fn)?;
323                    return Ok(res);
324                }
325                Err(VMError::RuntimeError(
326                    "Race join with empty task list".to_string(),
327                ))
328            }
329            // Any: return first success, skip errors.
330            2 => {
331                let mut last_err = None;
332                for &id in task_ids {
333                    match self.resolve_task(id, &mut executor_fn) {
334                        Ok(res) => return Ok(res),
335                        Err(e) => last_err = Some(e),
336                    }
337                }
338                Err(last_err.unwrap_or_else(|| {
339                    VMError::RuntimeError("Any join with empty task list".to_string())
340                }))
341            }
342            // AllSettled: drive every task, drop each result share, return
343            // a TaskGroup with kind=3 so the await-time decoder can rebuild
344            // the {status, value/error} array view (Phase-2c work — see
345            // ADR-006 §2.7.4).
346            3 => {
347                for &id in task_ids {
348                    if let Ok((bits, k)) = self.resolve_task(id, &mut executor_fn) {
349                        drop_with_kind(bits, k);
350                    }
351                    // Errors per-task are preserved in the scheduler's
352                    // result map; the caller can inspect via `get_result`.
353                }
354                let bits = Arc::into_raw(Arc::new(TaskGroupData {
355                    kind: 3,
356                    task_ids: task_ids.to_vec(),
357                })) as u64;
358                Ok((bits, NativeKind::Ptr(HeapKind::TaskGroup)))
359            }
360            _ => Err(VMError::RuntimeError(format!(
361                "Unknown join kind: {}",
362                kind
363            ))),
364        }
365    }
366}
367
368#[cfg(feature = "gc")]
369impl TaskScheduler {
370    /// Scan all heap-referencing roots held by the scheduler.
371    ///
372    /// **Phase-2c rebuild pending — ADR-006 §2.7.4.** Pre-bulldozer this
373    /// fed each `ValueWord` callable through `shape_gc::roots::trace_nanboxed_bits`,
374    /// which decoded tag bits to find heap pointers. Post-§2.7.7 the scheduler
375    /// stores `(u64, NativeKind)` pairs; the kinded GC root walker that takes
376    /// `(bits, kind)` is part of the deferred Phase-2c GC rebuild and is not
377    /// yet wired through `shape_gc::roots`. Surface as `todo!` so a stale
378    /// no-op trace doesn't silently miss live heap roots when GC is enabled.
379    pub(crate) fn scan_roots(&self, _visitor: &mut dyn FnMut(*mut u8)) {
380        todo!(
381            "phase-2c — ADR-006 §2.7.4: kinded GC root walker for TaskScheduler. \
382             The pre-bulldozer trace_nanboxed_bits path decoded ValueWord tag \
383             bits; the kinded equivalent (parallel kinds track + per-HeapKind \
384             dispatch via slot.as_heap_value()) belongs to the Phase-2c GC \
385             rebuild and is out of R-async-time scope."
386        )
387    }
388}
389
390impl Drop for TaskScheduler {
391    /// Release every heap-bearing share the scheduler still owns.
392    ///
393    /// Required to honor the §2.7.7 retain-on-store contract: every value
394    /// inserted via `register` / `complete` carries a strong-count share;
395    /// if the scheduler is dropped before consumers retire those shares,
396    /// `drop_with_kind` releases them here.
397    fn drop(&mut self) {
398        for (_, (bits, kind)) in self.callables.drain() {
399            drop_with_kind(bits, kind);
400        }
401        for (_, status) in self.results.drain() {
402            if let TaskStatus::Completed((bits, kind)) = status {
403                drop_with_kind(bits, kind);
404            }
405        }
406        // external_receivers: Receivers do not own scheduler-side shares;
407        // the share is in transit on the channel and the dropping receiver
408        // releases it on the sender side.
409    }
410}
411
412impl std::fmt::Debug for TaskScheduler {
413    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414        f.debug_struct("TaskScheduler")
415            .field("callables", &format!("[{} pending]", self.callables.len()))
416            .field("results", &format!("[{} entries]", self.results.len()))
417            .field(
418                "external_receivers",
419                &format!("[{} pending]", self.external_receivers.len()),
420            )
421            .finish()
422    }
423}
424
425impl Default for TaskScheduler {
426    fn default() -> Self {
427        Self::new()
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    /// Helper: a function-id "callable" — inline scalar payload. Post-W11
436    /// the dedicated `HeapKind::Function` variant was never added; the
437    /// `Future` variant has the same drop-shape (inline scalar, no
438    /// Arc-backed retain/release per `kinded_slot.rs:394`) and is the
439    /// stand-in test fixture for this scheduler-only register/take/resolve
440    /// cycle.
441    fn function_callable(func_id: u64) -> Kinded {
442        (func_id, NativeKind::Ptr(HeapKind::Future))
443    }
444
445    /// Helper: a float result.
446    fn float_result(v: f64) -> Kinded {
447        (v.to_bits(), NativeKind::Float64)
448    }
449
450    #[test]
451    fn test_register_and_take_callable() {
452        let mut sched = TaskScheduler::new();
453        let (bits, kind) = function_callable(42);
454        sched.register(1, bits, kind);
455        assert!(matches!(sched.get_result(1), Some(TaskStatus::Pending)));
456
457        let callable = sched.take_callable(1);
458        assert!(callable.is_some());
459
460        // Second take returns None (consumed)
461        assert!(sched.take_callable(1).is_none());
462    }
463
464    #[test]
465    fn test_resolve_task_synchronous() {
466        let mut sched = TaskScheduler::new();
467        let (b, k) = function_callable(0);
468        sched.register(1, b, k);
469
470        let result = sched.resolve_task(1, |_callable| Ok(float_result(99.0)));
471        assert!(result.is_ok());
472        let (bits, kind) = result.unwrap();
473        assert_eq!(kind, NativeKind::Float64);
474        assert!((f64::from_bits(bits) - 99.0).abs() < f64::EPSILON);
475
476        // Second resolve returns cached result (clone of the cached share).
477        let cached = sched.resolve_task(1, |_| panic!("should not be called"));
478        assert!(cached.is_ok());
479    }
480
481    #[test]
482    fn test_cancel_task() {
483        let mut sched = TaskScheduler::new();
484        let (b, k) = function_callable(0);
485        sched.register(1, b, k);
486
487        sched.cancel(1);
488        assert!(sched.is_resolved(1));
489
490        let result = sched.resolve_task(1, |_| Ok(float_result(0.0)));
491        assert!(result.is_err());
492    }
493
494    #[test]
495    fn test_resolve_all_group() {
496        let mut sched = TaskScheduler::new();
497        let (b1, k1) = function_callable(0);
498        let (b2, k2) = function_callable(1);
499        sched.register(1, b1, k1);
500        sched.register(2, b2, k2);
501
502        let mut call_count = 0u32;
503        let result = sched.resolve_task_group(0, &[1, 2], |_callable| {
504            call_count += 1;
505            Ok(float_result(call_count as f64))
506        });
507        assert!(result.is_ok());
508        let (_bits, kind) = result.unwrap();
509        // All-mode aggregate is a TaskGroup carrier (kinded TaskGroup ptr).
510        assert_eq!(kind, NativeKind::Ptr(HeapKind::TaskGroup));
511        assert_eq!(call_count, 2);
512    }
513
514    #[test]
515    fn test_resolve_race_group() {
516        let mut sched = TaskScheduler::new();
517        let (b1, k1) = function_callable(0);
518        let (b2, k2) = function_callable(1);
519        sched.register(10, b1, k1);
520        sched.register(20, b2, k2);
521
522        let result = sched.resolve_task_group(1, &[10, 20], |_| Ok(float_result(7.0)));
523        assert!(result.is_ok());
524        let (bits, kind) = result.unwrap();
525        assert_eq!(kind, NativeKind::Float64);
526        assert!((f64::from_bits(bits) - 7.0).abs() < f64::EPSILON);
527    }
528
529    #[test]
530    fn test_register_external_and_resolve() {
531        let mut sched = TaskScheduler::new();
532        let tx = sched.register_external(100);
533        assert!(sched.has_external(100));
534        assert!(matches!(sched.get_result(100), Some(TaskStatus::Pending)));
535
536        // Not yet resolved
537        assert!(sched.try_resolve_external(100).is_none());
538
539        // Send result from "background task"
540        tx.send(Ok(float_result(42.0))).unwrap();
541
542        // Now resolves
543        let result = sched.try_resolve_external(100);
544        assert!(result.is_some());
545        let (bits, kind) = result.unwrap().unwrap();
546        assert_eq!(kind, NativeKind::Float64);
547        assert!((f64::from_bits(bits) - 42.0).abs() < f64::EPSILON);
548
549        // Receiver removed after resolution
550        assert!(!sched.has_external(100));
551    }
552
553    #[test]
554    fn test_external_task_error() {
555        let mut sched = TaskScheduler::new();
556        let tx = sched.register_external(200);
557
558        tx.send(Err("connection refused".to_string())).unwrap();
559
560        let result = sched.try_resolve_external(200);
561        assert!(result.is_some());
562        assert!(result.unwrap().is_err());
563    }
564
565    #[test]
566    fn test_external_task_cancelled() {
567        let mut sched = TaskScheduler::new();
568        let tx = sched.register_external(300);
569
570        // Drop sender to simulate cancellation
571        drop(tx);
572
573        let result = sched.try_resolve_external(300);
574        assert!(result.is_some());
575        assert!(result.unwrap().is_err());
576    }
577
578    #[test]
579    fn test_take_external_receiver() {
580        let mut sched = TaskScheduler::new();
581        let _tx = sched.register_external(400);
582
583        assert!(sched.has_external(400));
584        let rx = sched.take_external_receiver(400);
585        assert!(rx.is_some());
586        assert!(!sched.has_external(400));
587    }
588}