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}