cove_runtime/task.rs
1//! Task scopes, task handles, and the task-safety rule at their boundary.
2//!
3//! The Language Card states the whole contract this module implements:
4//!
5//! > Concurrent work belongs to a task scope. Leaving the scope waits for or
6//! > cancels its child tasks. Immutable task-safe values such as arrays may
7//! > cross task boundaries. A vector cannot cross, even through `let`; finish
8//! > it as an array or wrap mutable state in `Shared` or another synchronized
9//! > type. Closures are task-safe only when every capture is.
10//!
11//! ADR 0008 runs a spawned task on a thread of its own, so a handle here owns
12//! that thread: the body starts when the task is created and the value it
13//! produces is reachable only by joining it, which is what `await` and
14//! leaving a scope both do. The state machine still holds no scheduling
15//! policy — `Tasking` below is what decides when a task is joined, and both
16//! evaluators reach it — because a value is observable through `await` or
17//! scope exit and through nothing else.
18//!
19//! A task's handle belongs to the thread that spawned it: [`Task`] is `Rc`
20//! and its state is a [`RefCell`], because only the spawning task ever
21//! touches it. What crosses the boundary is the body on the way in and the
22//! value on the way out, both as a [`Transfer`], plus a [`Cancellation`] the
23//! child observes at its own safepoints.
24
25use std::cell::{Cell, RefCell};
26use std::collections::{BTreeMap, BTreeSet};
27use std::rc::Rc;
28use std::sync::Arc;
29use std::thread::JoinHandle;
30use std::time::Duration;
31
32use cove_diag::Span;
33
34use crate::budget::Cancellation;
35use crate::error::RuntimeError;
36use crate::host::{HostRegistry, ResourceHandle};
37use crate::runtime::Runtime;
38use crate::shared::SharedCell;
39use crate::trace::TraceEvent;
40use crate::value::{
41 Closure, ClosureBody, DynValue, EnumValue, HostFnValue, MapKey, Repr, StructValue, Value,
42};
43use crate::wallclock::Instant;
44
45/// What a task thread hands back to the task that spawned it: the value the
46/// body produced, in the form that may cross the boundary, or why it stopped.
47pub type TaskOutcome = Result<Transfer, RuntimeError>;
48
49/// What a spawned task has done so far.
50#[derive(Debug)]
51pub enum TaskState {
52 /// The body is running on its own thread, which has not been joined yet.
53 Running,
54 /// The body produced a value. Awaiting again returns the same value.
55 Settled(Value),
56 /// The body raised a [`RuntimeError`]. Awaiting again raises the same one.
57 Failed(RuntimeError),
58 /// The task was cancelled: its own flag was raised, and it stopped at the
59 /// next safepoint rather than finishing. Awaiting a cancelled task is an
60 /// error.
61 Cancelled,
62}
63
64/// A spawned unit of work and the value it will produce.
65///
66/// The value is reachable only through [`TaskState::Settled`], so no caller
67/// can observe it without going through `await` or scope exit.
68pub struct Task {
69 /// Trace identity, unique across the run. Zero for a task that was
70 /// already settled when it was created, which never appears in a trace
71 /// because it never ran as a task.
72 pub id: u64,
73 /// The name of the scope that owns this task, for diagnostics.
74 pub scope: Rc<str>,
75 /// Position in spawn order within that scope, counting from one.
76 pub position: usize,
77 pub state: RefCell<TaskState>,
78 /// The thread running the body, until something joins it.
79 thread: RefCell<Option<JoinHandle<TaskOutcome>>>,
80 /// This task's own cancellation flag, raised by `cancel()` or by leaving
81 /// its scope early.
82 ///
83 /// It is separate from the run's flag on purpose: cancelling one task
84 /// stops that task, while cancelling the run stops everything. The child
85 /// observes both, since its safepoints charge the run's budget and check
86 /// this flag.
87 cancellation: Cancellation,
88}
89
90impl Task {
91 /// A task whose body is already running on `thread`.
92 pub fn running(
93 id: u64,
94 scope: Rc<str>,
95 position: usize,
96 cancellation: Cancellation,
97 thread: JoinHandle<TaskOutcome>,
98 ) -> Rc<Task> {
99 Rc::new(Task {
100 id,
101 scope,
102 position,
103 state: RefCell::new(TaskState::Running),
104 thread: RefCell::new(Some(thread)),
105 cancellation,
106 })
107 }
108
109 /// A task whose value is already known.
110 ///
111 /// An `async fn` is called like any other function and runs its body at
112 /// the call site, so the handle it returns is settled on creation. ADR
113 /// 0008 gives a thread to `spawn`, which is where the language says
114 /// concurrency begins; nothing may depend on when an `async fn` body ran,
115 /// only on the value `await` produces.
116 pub fn settled(value: Value) -> Rc<Task> {
117 Rc::new(Task {
118 id: 0,
119 scope: "this call".into(),
120 position: 0,
121 state: RefCell::new(TaskState::Settled(value)),
122 thread: RefCell::new(None),
123 cancellation: Cancellation::new(),
124 })
125 }
126
127 /// Whether the body is still running on its own thread.
128 pub fn is_running(&self) -> bool {
129 matches!(&*self.state.borrow(), TaskState::Running)
130 }
131
132 /// Whether this task's own cancellation was requested.
133 pub fn is_cancelled(&self) -> bool {
134 self.cancellation.is_cancelled()
135 }
136
137 /// Asks this task to stop at its next safepoint.
138 ///
139 /// A task that has already finished is unaffected: cancellation stops
140 /// work that has not happened, it does not undo work that has. Nothing
141 /// here waits — [`Task::join`] is what waits — because a scope cancels
142 /// all of its children before waiting for any of them.
143 pub fn cancel(&self) {
144 if self.is_running() {
145 self.cancellation.cancel();
146 }
147 }
148
149 /// Waits for the body's thread and records what it produced, unless the
150 /// task has already been joined.
151 ///
152 /// A task's body runs at most once and is joined at most once, so
153 /// awaiting the same handle twice returns the same value and repeats no
154 /// effect.
155 pub fn join(&self) {
156 let Some(thread) = self.thread.borrow_mut().take() else {
157 return;
158 };
159 let outcome = match thread.join() {
160 Ok(outcome) => outcome,
161 // A panic is a broken invariant in the task's own thread. The
162 // panic message has already reached stderr; what the spawning
163 // task needs is an error rather than a value that never arrived.
164 Err(_) => Err(broken_invariant(&self.describe())),
165 };
166 *self.state.borrow_mut() = match outcome {
167 Ok(value) => TaskState::Settled(value.into_value()),
168 // A task that stopped after its own cancellation was requested is
169 // cancelled, not failed: that is the stop the scope asked for.
170 Err(_) if self.is_cancelled() => TaskState::Cancelled,
171 Err(error) => TaskState::Failed(error),
172 };
173 }
174
175 /// How this task is named in diagnostics.
176 pub fn describe(&self) -> String {
177 describe(self.position, &self.scope)
178 }
179}
180
181/// How a task is named in a diagnostic: `task 2 of scope `requests``.
182///
183/// A free function rather than a method, because the linear-memory backend
184/// holds a task's identity in a scheduler table rather than in a [`Task`] and
185/// still has to name one in the same words. Two backends wording the same
186/// sentence twice is exactly what the differential corpus catches after the
187/// fact and what one function prevents.
188///
189/// Position zero is a task that never ran as one — an `async fn` whose handle
190/// was settled on creation — and has no place in a scope to name.
191pub(crate) fn describe(position: usize, scope: &str) -> String {
192 if position == 0 {
193 "this task".to_string()
194 } else {
195 format!("task {position} of scope `{scope}`")
196 }
197}
198
199/// A `spawn` into a scope that has already been left.
200pub(crate) fn scope_already_left(name: &str, span: Span) -> RuntimeError {
201 RuntimeError::new(format!(
202 "scope `{name}` has already been left, so it can take no more tasks"
203 ))
204 .at(span)
205 .with_rule("Leaving a task scope waits for or cancels its child tasks.")
206}
207
208/// A `spawn` on a target that has no threads of its own.
209///
210/// ADR 0008 makes a Cove task a thread, and `wasm32-unknown-unknown` has
211/// none to give: a Web Worker is one thread and has no way to make a second,
212/// and `std::thread::Builder::spawn` there traps rather than returning an
213/// error a caller could report. So a `spawn`
214/// is refused before anything is charged, in the ordinary way a run stops —
215/// a [`RuntimeError`] with a span — rather than emulated by running the body
216/// inline. Running it inline would be the quiet failure: the program would
217/// answer, and it would answer something the tree-walking oracle, which
218/// really does spawn, need not agree with. The corpus is held together by
219/// those two agreeing.
220///
221/// [`crate::trace::RunOutcome::Concurrency`] is the classification because it
222/// already means "a `spawn` could not be given a task", and a target with no
223/// threads is the limiting case: no task may be alive at once. A second
224/// outcome for the same sentence would be a second vocabulary for one fact.
225///
226/// Both backends call this at the same point — after the checks that are
227/// about the program (the scope is open, the body is a closure, every capture
228/// may cross) and before the concurrency limit is charged — so a program that
229/// is wrong about its own `spawn` gets the same diagnostic here as anywhere
230/// else, and only a `spawn` that would otherwise have succeeded is refused.
231pub(crate) fn no_threads_here(span: Span) -> RuntimeError {
232 RuntimeError::new("`spawn` cannot run in this environment, which has no threads to give a task")
233 .at(span)
234 .with_rule(crate::budget::RULE)
235 .with_outcome(crate::trace::RunOutcome::Concurrency)
236 .with_help("run this program where a task can have a thread; `cove run` does")
237}
238
239/// A task whose own thread ended in a panic.
240pub(crate) fn broken_invariant(described: &str) -> RuntimeError {
241 RuntimeError::new(format!("{described} ended in a broken invariant"))
242}
243
244/// A task shows as what it is, never as the value it will produce: that value
245/// is observable through `await` or scope exit and through nothing else.
246impl std::fmt::Debug for Task {
247 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
248 f.write_str(&self.describe())
249 }
250}
251
252/// The task scope `scope name { ... }` binds.
253///
254/// The scope owns every task spawned into it, which is what lets leaving the
255/// scope wait for or cancel its children.
256#[derive(Debug)]
257pub struct TaskScope {
258 /// The name the scope is bound to, for diagnostics.
259 pub name: Rc<str>,
260 /// Child tasks in spawn order.
261 pub tasks: RefCell<Vec<Rc<Task>>>,
262 /// Set once the scope has been left; a handle that outlives its scope can
263 /// no longer spawn into it.
264 closed: Cell<bool>,
265}
266
267impl TaskScope {
268 pub fn new(name: Rc<str>) -> Rc<TaskScope> {
269 Rc::new(TaskScope {
270 name,
271 tasks: RefCell::new(Vec::new()),
272 closed: Cell::new(false),
273 })
274 }
275
276 /// Adopts a task that is already running, and returns its handle.
277 pub fn adopt(&self, task: Rc<Task>) -> Rc<Task> {
278 self.tasks.borrow_mut().push(task.clone());
279 task
280 }
281
282 /// The position the next task spawned into this scope will have.
283 pub fn next_position(&self) -> usize {
284 self.tasks.borrow().len() + 1
285 }
286
287 /// The task at `index` in spawn order, if the scope has one.
288 pub fn task_at(&self, index: usize) -> Option<Rc<Task>> {
289 self.tasks.borrow().get(index).cloned()
290 }
291
292 /// Asks every child that is still running to stop.
293 pub fn cancel_running(&self) {
294 for task in self.tasks.borrow().iter() {
295 task.cancel();
296 }
297 }
298
299 pub fn is_closed(&self) -> bool {
300 self.closed.get()
301 }
302
303 pub fn close(&self) {
304 self.closed.set(true);
305 }
306}
307
308// ------------------------------------------------ the interpreter's tasking
309
310/// What an evaluator has to answer for a task to be spawned into a scope,
311/// waited for, and charged for.
312///
313/// [`crate::interp::Interpreter`] is this trait's one implementor now. Before
314/// ADR 0034 it had a second: the predecessor VM shared this exact machinery,
315/// which is why `spawn`, `await`, and leaving a scope are written once here
316/// rather than twice, with only which evaluator runs a body and which timing
317/// contexts a wait is charged against left for an implementor to answer. The
318/// linear-memory backend was written clean-room rather than as a renovation
319/// of that VM, and it keeps its own task and scope bookkeeping in
320/// `crate::vm` — see that module's docs — instead of implementing this
321/// trait. What still holds the two backends to one answer is the
322/// differential corpus rather than shared Rust code: a task-safety rule, a
323/// budget charge, or a trace event that drifted between them is exactly what
324/// that corpus exists to catch.
325///
326/// An implementor holds a run's [`Runtime`], a stack of timings, and the id
327/// of the task it is running, which is what ADR 0008's "each task gets an
328/// evaluator of its own" asks of it.
329pub(crate) trait Tasking {
330 /// What every thread of this run shares, which is what a `spawn` hands
331 /// the thread it starts.
332 fn runtime(&self) -> &Runtime;
333
334 /// The host boundary, which owns the run's budget.
335 fn hosts(&self) -> &HostRegistry;
336
337 /// Records `wait` against every timing context this body is inside.
338 ///
339 /// A body blocked on `await` is doing nothing, exactly as a body blocked
340 /// on a host call is, so the two are charged the same way and a trace can
341 /// tell a scope that waited for two tasks from one that computed for as
342 /// long as they ran.
343 fn charge_wait(&mut self, wait: Duration);
344
345 /// The task whose body this evaluator is running, or `None` for the
346 /// entry, so that a nested `spawn` can name its immediate parent.
347 fn running_task(&self) -> Option<u64>;
348}
349
350/// `scope.spawn { ... }`: starts a thread for `body` and hands back the
351/// handle the scope now owns.
352///
353/// Converting the closure for the new thread *is* the task-safety check:
354/// what may cross a task boundary is exactly what a thread can own, so a
355/// capture that may not cross is reported at the `spawn` that would have
356/// carried it, before any thread exists.
357///
358/// This returns once the thread exists and orders nothing else: whether the
359/// child has run an instruction by the time the parent's next statement runs
360/// is the operating system's answer, not this runtime's. A rendezvous here
361/// would be a scheduling policy, which ADR 0008's amendment refuses for the
362/// same reason the concurrency limit below refuses to wait.
363///
364/// `run` is the whole of what a backend contributes: it receives a
365/// [`Runtime`] of its own, the id, the flag the body observes, and the body
366/// in the form that crossed, and it evaluates it on the new thread. The
367/// evaluator it builds there is the receiving task's; nothing of this one
368/// crosses, because nothing of this one could.
369pub(crate) fn spawn_into<H: Tasking>(
370 host: &mut H,
371 scope: &Rc<TaskScope>,
372 body: Value,
373 span: Span,
374 run: impl FnOnce(Runtime, u64, Cancellation, Transfer, Span) -> TaskOutcome + Send + 'static,
375) -> Result<Value, RuntimeError> {
376 if scope.is_closed() {
377 return Err(scope_already_left(&scope.name, span));
378 }
379 if !matches!(body, Value(Repr::Closure(_))) {
380 return Err(RuntimeError::new(format!(
381 "`spawn` takes the work to run as a trailing closure, but found `{}`",
382 body.type_name()
383 ))
384 .at(span)
385 .with_help(format!("write `{}.spawn {{ ... }}`", scope.name)));
386 }
387 let body = Transfer::of(&body).map_err(|found| {
388 RuntimeError::new(format!(
389 "`spawn` cannot capture `{}`, which is a `{}`",
390 found.path, found.type_name
391 ))
392 .at(span)
393 .with_rule(TASK_SAFETY_RULE)
394 .with_help(found.help("spawning"))
395 })?;
396
397 // Everything above is about the program and is decided the same way
398 // everywhere. Everything below needs a thread, and `no_threads_here` says
399 // what it means for there not to be one.
400 if cfg!(target_arch = "wasm32") {
401 return Err(no_threads_here(span));
402 }
403
404 // Charged before this task is given an id, an event, or a thread: a
405 // thread that has started is a resource already taken, which no later
406 // safepoint could refuse. A run past its concurrency limit is stopped
407 // here the way an exhausted fuel budget stops one, rather than made to
408 // wait for a sibling to end, because waiting would be a scheduling
409 // policy and ADR 0008 has none.
410 if let Some(Err(error)) = host.hosts().with_budget(|budget| {
411 budget
412 .charge_task()
413 .map_err(|stopped| budget.to_runtime_error(stopped))
414 }) {
415 return Err(error.at(span));
416 }
417
418 let runtime = host.runtime().clone();
419 let id = runtime.next_task_id();
420 // Traced before the thread starts, so a task is never seen completing
421 // before it was seen spawning.
422 runtime.trace(TraceEvent::TaskSpawned {
423 id,
424 parent: host.running_task(),
425 scope: scope.name.to_string(),
426 });
427
428 let cancellation = Cancellation::new();
429 let flag = cancellation.clone();
430 let thread = std::thread::Builder::new()
431 .name(format!("cove task {id}"))
432 // A task evaluates Cove, so it gets the stack the depth limit is
433 // calibrated against rather than whatever the platform hands a thread
434 // by default. Without this a task overflows its stack long before
435 // `MAX_CALL_DEPTH` stops it, which ends the process and takes every
436 // sibling task with it.
437 .stack_size(crate::interp::STACK_SIZE)
438 .spawn(move || run(runtime, id, flag, body, span))
439 .map_err(|e| {
440 // A task the machine refused is not a task the run holds, so the
441 // place charged for it above goes back.
442 host.hosts().with_budget(|budget| budget.release_task());
443 RuntimeError::new(format!("this task could not be given a thread: {e}")).at(span)
444 })?;
445
446 let task = Task::running(
447 id,
448 scope.name.clone(),
449 scope.next_position(),
450 cancellation,
451 thread,
452 );
453 Ok(Value(Repr::Task(scope.adopt(task))))
454}
455
456/// Waits for a task's thread, charging the time against this body's timings
457/// as wait rather than as work.
458///
459/// This is also the one place that learns whether a cancellation actually
460/// stopped a task, so it is where `TaskCancelled` is traced. A task is waited
461/// for once, so the event is recorded once; a task that had already finished
462/// is unaffected by cancellation, and tracing it as cancelled would say work
463/// was stopped that in fact happened.
464pub(crate) fn join<H: Tasking>(host: &mut H, task: &Rc<Task>) {
465 if !task.is_running() {
466 return;
467 }
468 let started = Instant::now();
469 task.join();
470 // A task ends by finishing, by failing, by being cancelled, or by
471 // breaking an invariant in its own thread, and a join is where all four
472 // are observed — so this is where the place it held under the
473 // concurrency limit goes back. Releasing it on the task's own thread
474 // instead would make what a `spawn` is refused for depend on how quickly
475 // a sibling happened to finish.
476 host.hosts().with_budget(|budget| budget.release_task());
477 host.charge_wait(started.elapsed());
478 if matches!(&*task.state.borrow(), TaskState::Cancelled) {
479 host.runtime()
480 .trace(TraceEvent::TaskCancelled { id: task.id });
481 }
482}
483
484/// `await`: waits for a task's thread and answers the value its body produced.
485///
486/// A task's body runs at most once and is waited for at most once, so
487/// awaiting the same handle twice returns the same value and repeats no
488/// effect.
489pub(crate) fn settle<H: Tasking>(
490 host: &mut H,
491 task: &Rc<Task>,
492 span: Span,
493) -> Result<Value, RuntimeError> {
494 join(host, task);
495 match &*task.state.borrow() {
496 TaskState::Settled(value) => Ok(value.clone()),
497 TaskState::Failed(error) => Err(error.clone()),
498 TaskState::Cancelled => Err(awaiting_a_cancelled_task(task, span)),
499 TaskState::Running => {
500 unreachable!("joining a task leaves it settled, failed, or cancelled")
501 }
502 }
503}
504
505/// Cancels every running child of `scope` and waits for it to stop.
506///
507/// Every child is asked first and waited for afterwards, so they stop at the
508/// same time rather than one after another. Leaving a scope waits for or
509/// cancels its children, so this does both: a scope never outlives a thread
510/// it started.
511pub(crate) fn cancel_children<H: Tasking>(host: &mut H, scope: &Rc<TaskScope>) {
512 scope.cancel_running();
513 let mut index = 0;
514 while let Some(task) = scope.task_at(index) {
515 index += 1;
516 join(host, &task);
517 }
518}
519
520/// How a child ended, where that is something the scope has to pass on.
521pub(crate) enum ChildFailure {
522 /// The task's value was `Err(...)`, already wrapped as the value the
523 /// enclosing call is to answer.
524 ///
525 /// A task whose value is a failed `Result` returns that failure from the
526 /// function the scope was written in, exactly as `?` would, which is what
527 /// makes `scope s { s.spawn { f()? } }` mean what a reader expects: the
528 /// failure reaches the caller rather than sitting unread in a handle
529 /// nobody awaited.
530 Returned(Value),
531 /// The task's body raised. The error propagates as itself.
532 Raised(RuntimeError),
533}
534
535/// Waits for every task the body did not await, in spawn order, and reports
536/// the first child that did not simply finish.
537///
538/// A task that fails is not swallowed — a [`RuntimeError`] propagates as
539/// itself, and a task whose value is `Err(error)` returns that error from the
540/// enclosing function, exactly as `?` would. A task the program itself
541/// cancelled is neither: the program asked for that stop, so leaving the
542/// scope is not the place to complain about it. Either way the tasks still
543/// running are cancelled and waited for, which is what the caller does with
544/// what this answers.
545///
546/// Waiting happens in spawn order, which is an order of *observation* only:
547/// the tasks ran at the same time on threads of their own, so only the set of
548/// effects a scope produces is defined, never their sequence.
549pub(crate) fn wait_for_children<H: Tasking>(
550 host: &mut H,
551 scope: &Rc<TaskScope>,
552) -> Option<ChildFailure> {
553 // Waiting reads the scope's children by index rather than from a
554 // snapshot, so a scope that grew while it was being left is still waited
555 // for to the end.
556 let mut index = 0;
557 while let Some(task) = scope.task_at(index) {
558 index += 1;
559 if !task.is_running() {
560 continue;
561 }
562 join(host, &task);
563 // The state is read and released before anything else runs, so
564 // cancelling the rest of the scope can borrow these same tasks.
565 let outcome = match &*task.state.borrow() {
566 TaskState::Settled(value) => {
567 failure_of(value).map(|error| ChildFailure::Returned(Value::err(error)))
568 }
569 TaskState::Failed(error) => Some(ChildFailure::Raised(error.clone())),
570 TaskState::Cancelled | TaskState::Running => None,
571 };
572 if outcome.is_some() {
573 return outcome;
574 }
575 }
576 None
577}
578
579/// The trace event a finished task's thread writes, and the form its value
580/// crosses back in.
581///
582/// A task stopped by its own cancellation did not run to completion, so it is
583/// traced as cancelled — by whoever waits for it, which is the only place
584/// that knows it stopped rather than finished — and not here.
585pub(crate) fn finished(
586 runtime: &Runtime,
587 id: u64,
588 cancellation: &Cancellation,
589 span: Span,
590 result: Result<Value, RuntimeError>,
591 cpu: Duration,
592) -> TaskOutcome {
593 if !(result.is_err() && cancellation.is_cancelled()) {
594 runtime.trace(TraceEvent::TaskCompleted { id, cpu });
595 }
596 let value = result?;
597 Transfer::of(&value).map_err(|found| {
598 RuntimeError::new(format!(
599 "this task produced {}, which cannot leave a task",
600 found.subject()
601 ))
602 .at(span)
603 .with_rule(TASK_SAFETY_RULE)
604 .with_help(found.help("returning it from a task"))
605 })
606}
607
608/// The error a `Result` carries, when the value is one and it failed.
609fn failure_of(value: &Value) -> Option<Value> {
610 value
611 .err_payload()
612 .map(|payload| payload.first().cloned().unwrap_or(Value(Repr::Unit)))
613}
614
615fn awaiting_a_cancelled_task(task: &Task, span: Span) -> RuntimeError {
616 awaiting_a_cancelled(&task.describe(), span)
617}
618
619/// `await` on a task the program cancelled, in the words both backends use.
620pub(crate) fn awaiting_a_cancelled(described: &str, span: Span) -> RuntimeError {
621 RuntimeError::new(format!(
622 "{described} was cancelled, so it has no value to await"
623 ))
624 .at(span)
625 .with_rule("Leaving a task scope waits for or cancels its child tasks, and a cancelled task never runs.")
626 .with_help("await the task before cancelling it, and before leaving its scope early")
627}
628
629// ------------------------------------------------------------ task safety
630
631/// The first value in a capture that may not cross a task boundary.
632#[derive(Clone, Debug)]
633pub struct NotTaskSafe {
634 /// How the offending value is reached from the closure, such as
635 /// `app.metrics` or `handler -> builder`.
636 pub path: String,
637 /// The type that is not task-safe.
638 pub type_name: String,
639}
640
641/// A task-safe value in the form the receiving task can own.
642///
643/// The interpreter's [`Value`] is reference-counted with `Rc`, so it belongs
644/// to the thread that built it, while a value crossing a task boundary has to
645/// be owned by the thread that receives it. ADR 0008 observes that those are
646/// one condition: the Language Card lets a value cross exactly when copying
647/// it is the whole of transferring it, which is what a thread requires too.
648///
649/// So this is not a second task-safety rule standing beside the first — it
650/// *is* the rule, and the walk that once only answered it was replaced by
651/// this one. [`Transfer::of`] answers both questions in one walk: whether
652/// the value may cross, and what the receiving task owns once it has.
653#[derive(Clone, Debug)]
654pub enum Transfer {
655 Unit,
656 Bool(bool),
657 Int(i64),
658 Float(f64),
659 Duration(i64),
660 Str(String),
661 Array(Vec<Transfer>),
662 Map(BTreeMap<MapKey, Transfer>),
663 Set(BTreeSet<MapKey>),
664 Struct {
665 type_name: String,
666 fields: Vec<(String, Transfer)>,
667 /// Whether the type is opaque, so that a value rebuilt in the
668 /// receiving task keeps rendering as its name alone.
669 opaque: bool,
670 },
671 Enum {
672 type_name: String,
673 case: String,
674 payload: Vec<Transfer>,
675 },
676 Dyn {
677 trait_name: String,
678 value: Box<Transfer>,
679 },
680 Closure(Box<TransferClosure>),
681 HostModule(String),
682 HostFn {
683 module: String,
684 op: String,
685 },
686 Type(String),
687 Range {
688 start: i64,
689 end: i64,
690 inclusive_end: bool,
691 },
692 /// The one value that crosses by sharing rather than by copying: both
693 /// sides address the same [`SharedCell`], which is what makes `Shared`
694 /// the sanctioned way to hold mutable state across tasks.
695 Shared(Arc<SharedCell>),
696 /// A resource handle, when its schema says it may cross.
697 ///
698 /// A handle is a name and nothing else, so it crosses the way a string
699 /// does. What decides is not the handle but the resource: a host that
700 /// keeps a connection behind a lock says so in its
701 /// [`crate::schema::ResourceSchema`], and the answer travels on the
702 /// handle so this walk can read it.
703 Resource(Arc<ResourceHandle>),
704}
705
706/// The parts of a [`Closure`] a receiving task can own.
707///
708/// The body carries the declaration with it and is shared rather than copied
709/// wherever it can be: an [`Arc<Block>`] is immutable syntax, so two threads
710/// reading the same one observe nothing about each other. Only the captures
711/// are converted, which is where the task-safety rule has anything to decide.
712#[derive(Clone, Debug)]
713pub struct TransferClosure {
714 pub is_async: bool,
715 /// How many parameters the closure declares — a number, and so nothing
716 /// the task-safety rule has to decide about.
717 pub arity: usize,
718 /// The body, in whichever of the two forms the backend that made this
719 /// closure builds.
720 ///
721 /// [`ClosureBody::Tree`] is syntax and crosses the way the declaration
722 /// inside it does: an `Arc<Block>` is immutable, so two threads reading
723 /// one observe nothing about each other, and the parameters beside it are
724 /// copied like any other owned syntax. [`ClosureBody::Linear`] is a
725 /// [`crate::value::LinearClosure`] naming a function in *one run's* `cove_ir::Program`
726 /// and the heap object it closes over, so what its `FunctionId` means
727 /// depends on which program the receiving task is running against — and
728 /// the answer is that every task of a run runs against the same one,
729 /// immutable once lowered. The heap object crosses unconverted alongside
730 /// it, because ADR 0034 makes the object heap the run's rather than the
731 /// task's, so an address made on one task's thread is good on another's.
732 /// Lowering the program a second time on the receiving thread would not
733 /// have given the same `FunctionId`, which is why nothing does.
734 pub body: ClosureBody,
735 pub module: String,
736 pub captures: Vec<(String, Transfer)>,
737}
738
739impl Transfer {
740 /// Converts `value` into the form a receiving task owns, or reports the
741 /// first part of it that may not cross a task boundary.
742 pub fn of(value: &Value) -> Result<Transfer, NotTaskSafe> {
743 Transfer::convert("", value)
744 }
745
746 /// Whether `target` is reachable from this transfer without passing
747 /// through a second [`SharedCell`].
748 ///
749 /// [`SharedCell::lock`] calls this on the value it is about to store,
750 /// with `target` the cell being locked, to reject the one shape of cycle
751 /// ADR 0011 makes cheap to catch: a cell ending up holding a handle to
752 /// itself. `Transfer::Shared` holds an `Arc` handle, not the other cell's
753 /// contents — those sit behind a `Mutex` this walk does not take — so
754 /// the search stops at every `Shared` it meets and never risks the
755 /// deadlock or unbounded work that chasing into another cell could
756 /// cause. A cycle through two or more cells is invisible to this check;
757 /// that is the wider, deferred problem the ADR's amendment names.
758 ///
759 /// A [`Transfer::Resource`] is a leaf here for the same reason it is a
760 /// leaf everywhere else: a handle is a name the host resolves, not a
761 /// container, so nothing is reachable through one.
762 pub(crate) fn reaches(&self, target: *const SharedCell) -> bool {
763 match self {
764 Transfer::Shared(cell) => std::ptr::eq(Arc::as_ptr(cell), target),
765 Transfer::Array(items) => items.iter().any(|item| item.reaches(target)),
766 Transfer::Map(entries) => entries.values().any(|item| item.reaches(target)),
767 Transfer::Struct { fields, .. } => fields.iter().any(|(_, item)| item.reaches(target)),
768 Transfer::Enum { payload, .. } => payload.iter().any(|item| item.reaches(target)),
769 Transfer::Dyn { value, .. } => value.reaches(target),
770 Transfer::Closure(closure) => closure
771 .captures
772 .iter()
773 .any(|(_, item)| item.reaches(target)),
774 Transfer::Unit
775 | Transfer::Bool(_)
776 | Transfer::Int(_)
777 | Transfer::Float(_)
778 | Transfer::Duration(_)
779 | Transfer::Str(_)
780 | Transfer::Set(_)
781 | Transfer::HostModule(_)
782 | Transfer::HostFn { .. }
783 | Transfer::Type(_)
784 | Transfer::Range { .. }
785 | Transfer::Resource(_) => false,
786 }
787 }
788
789 /// `path` names how the value was reached, and is extended as the walk
790 /// descends, so a diagnostic can point at the capture rather than at the
791 /// closure as a whole.
792 fn convert(path: &str, value: &Value) -> Result<Transfer, NotTaskSafe> {
793 match value {
794 // Primitives, strings, and ranges are values; copying one is the
795 // whole of transferring it.
796 Value(Repr::Unit) => Ok(Transfer::Unit),
797 Value(Repr::Bool(b)) => Ok(Transfer::Bool(*b)),
798 Value(Repr::Int(n)) => Ok(Transfer::Int(*n)),
799 Value(Repr::Float(x)) => Ok(Transfer::Float(*x)),
800 Value(Repr::Duration(ns)) => Ok(Transfer::Duration(*ns)),
801 Value(Repr::Str(s)) => Ok(Transfer::Str(s.to_string())),
802 Value(Repr::Range {
803 start,
804 end,
805 inclusive_end,
806 }) => Ok(Transfer::Range {
807 start: *start,
808 end: *end,
809 inclusive_end: *inclusive_end,
810 }),
811 // A vector is growable shared mutable storage, so it cannot cross
812 // even through `let`: the `let` restricts this alias, not the
813 // storage.
814 Value(Repr::Vector(_)) => Err(NotTaskSafe {
815 path: path.to_string(),
816 type_name: value.type_name(),
817 }),
818 // `Array` and `Map` are immutable, so they cross exactly when
819 // everything they contain does.
820 Value(Repr::Array(items)) => {
821 let mut converted = Vec::with_capacity(items.len());
822 for (i, item) in items.iter().enumerate() {
823 converted.push(Transfer::convert(&format!("{path}[{i}]"), item)?);
824 }
825 Ok(Transfer::Array(converted))
826 }
827 // A `Set` element is a `MapKey`: always `Bool`, `Int`, `Str`, or a
828 // payload-free enum case, all of which are unconditionally
829 // task-safe.
830 Value(Repr::Set(items)) => Ok(Transfer::Set((**items).clone())),
831 Value(Repr::Map(entries)) => {
832 let mut converted = BTreeMap::new();
833 for (key, item) in entries.iter() {
834 converted.insert(
835 key.clone(),
836 Transfer::convert(&format!("{path}[{key}]"), item)?,
837 );
838 }
839 Ok(Transfer::Map(converted))
840 }
841 Value(Repr::Struct(structure)) => {
842 let mut fields = Vec::with_capacity(structure.fields.len());
843 for (name, field) in &structure.fields {
844 fields.push((
845 name.to_string(),
846 Transfer::convert(&format!("{path}.{name}"), field)?,
847 ));
848 }
849 Ok(Transfer::Struct {
850 type_name: structure.type_name.to_string(),
851 fields,
852 opaque: structure.opaque,
853 })
854 }
855 Value(Repr::Enum(enumeration)) => {
856 let mut payload = Vec::with_capacity(enumeration.payload.len());
857 for (i, item) in enumeration.payload.iter().enumerate() {
858 payload.push(Transfer::convert(
859 &format!("{path}.{}({i})", enumeration.case),
860 item,
861 )?);
862 }
863 Ok(Transfer::Enum {
864 type_name: enumeration.type_name.to_string(),
865 case: enumeration.case.to_string(),
866 payload,
867 })
868 }
869 // A trait object is task-safe exactly when the value it holds is:
870 // the wrapper adds a trait name, which is not state.
871 Value(Repr::Dyn(d)) => Ok(Transfer::Dyn {
872 trait_name: d.trait_name.to_string(),
873 value: Box::new(Transfer::convert(path, &d.value)?),
874 }),
875 // Closures are task-safe only when every capture is.
876 Value(Repr::Closure(closure)) => {
877 let mut captures = Vec::with_capacity(closure.captures.len());
878 for (name, captured) in &closure.captures {
879 let capture_path = if path.is_empty() {
880 name.to_string()
881 } else {
882 format!("{path} -> {name}")
883 };
884 captures.push((
885 name.to_string(),
886 Transfer::convert(&capture_path, captured)?,
887 ));
888 }
889 Ok(Transfer::Closure(Box::new(TransferClosure {
890 is_async: closure.is_async,
891 arity: closure.arity,
892 body: closure.body.clone(),
893 module: closure.module.to_string(),
894 captures,
895 })))
896 }
897 // A host module or operation is a name, not state. What a host
898 // call *produces* declares its own task-safety in the Host API
899 // schema, which [`crate::host::HostRegistry::result_is_task_safe`]
900 // reads; addressing the module is not itself a transfer of state,
901 // and the grant check still happens at the call.
902 Value(Repr::HostModule(module)) => Ok(Transfer::HostModule(module.to_string())),
903 Value(Repr::HostFn(host)) => Ok(Transfer::HostFn {
904 module: host.module.to_string(),
905 op: host.op.to_string(),
906 }),
907 Value(Repr::Type(name)) => Ok(Transfer::Type(name.to_string())),
908 // "Host resources declare task-safety in their Host API schema."
909 // The handle carries that declaration, so a resource the host
910 // keeps to one task is refused here exactly like a vector.
911 Value(Repr::Resource(handle)) if handle.task_safe => {
912 Ok(Transfer::Resource(handle.clone()))
913 }
914 Value(Repr::Resource(_)) => Err(NotTaskSafe {
915 path: path.to_string(),
916 type_name: value.type_name(),
917 }),
918 // A `Shared` is the one exception to the copy rule: it crosses by
919 // sharing the cell, which is the reason the type exists.
920 Value(Repr::Shared(cell)) => Ok(Transfer::Shared(cell.clone())),
921 // A scope and a handle belong to the task that holds them: a child
922 // may not spawn into its parent's scope or await its siblings.
923 // That keeps the scope's children a set its own body decides.
924 Value(Repr::TaskScope(_)) | Value(Repr::Task(_)) => Err(NotTaskSafe {
925 path: path.to_string(),
926 type_name: value.type_name(),
927 }),
928 }
929 }
930
931 /// Rebuilds the value in the receiving task, where it is an ordinary
932 /// [`Value`] again with no trace of having crossed.
933 pub fn into_value(self) -> Value {
934 match self {
935 Transfer::Unit => Value(Repr::Unit),
936 Transfer::Bool(b) => Value(Repr::Bool(b)),
937 Transfer::Int(n) => Value(Repr::Int(n)),
938 Transfer::Float(x) => Value(Repr::Float(x)),
939 Transfer::Duration(ns) => Value(Repr::Duration(ns)),
940 Transfer::Str(s) => Value(Repr::Str(s.into())),
941 Transfer::Array(items) => Value(Repr::Array(
942 items.into_iter().map(Transfer::into_value).collect(),
943 )),
944 Transfer::Map(entries) => Value(Repr::Map(Rc::new(
945 entries
946 .into_iter()
947 .map(|(key, value)| (key, value.into_value()))
948 .collect(),
949 ))),
950 Transfer::Set(items) => Value(Repr::Set(Rc::new(items))),
951 Transfer::Struct {
952 type_name,
953 fields,
954 opaque,
955 } => Value(Repr::Struct(Rc::new(StructValue {
956 type_name: type_name.into(),
957 fields: fields
958 .into_iter()
959 .map(|(name, value)| (name.into(), value.into_value()))
960 .collect(),
961 opaque,
962 }))),
963 Transfer::Enum {
964 type_name,
965 case,
966 payload,
967 } => Value(Repr::Enum(Box::new(EnumValue {
968 type_name: type_name.into(),
969 case: case.into(),
970 payload: payload.into_iter().map(Transfer::into_value).collect(),
971 }))),
972 Transfer::Dyn { trait_name, value } => Value(Repr::Dyn(Rc::new(DynValue {
973 trait_name: trait_name.into(),
974 value: value.into_value(),
975 }))),
976 Transfer::Closure(closure) => {
977 let closure = *closure;
978 Value(Repr::Closure(Rc::new(Closure {
979 is_async: closure.is_async,
980 arity: closure.arity,
981 body: closure.body,
982 module: closure.module.into(),
983 captures: closure
984 .captures
985 .into_iter()
986 .map(|(name, value)| (name.into(), value.into_value()))
987 .collect(),
988 })))
989 }
990 Transfer::HostModule(module) => Value(Repr::HostModule(module.into())),
991 Transfer::HostFn { module, op } => Value(Repr::HostFn(Rc::new(HostFnValue {
992 module: module.into(),
993 op: op.into(),
994 }))),
995 Transfer::Type(name) => Value(Repr::Type(name.into())),
996 Transfer::Range {
997 start,
998 end,
999 inclusive_end,
1000 } => Value(Repr::Range {
1001 start,
1002 end,
1003 inclusive_end,
1004 }),
1005 Transfer::Shared(cell) => Value(Repr::Shared(cell)),
1006 Transfer::Resource(handle) => Value(Repr::Resource(handle)),
1007 }
1008 }
1009}
1010
1011impl NotTaskSafe {
1012 /// How the offending value is named in a diagnostic: as the type itself
1013 /// when the value under test *is* the problem, and with the path to it
1014 /// when the problem is nested inside a larger value.
1015 ///
1016 /// A path is written as it is reached from the value under test, so a
1017 /// leading `.` is dropped: the root has no name to hang it on.
1018 pub fn subject(&self) -> String {
1019 match self.path.trim_start_matches('.') {
1020 "" => format!("a `{}`", self.type_name),
1021 path => format!("a `{}` in `{path}`", self.type_name),
1022 }
1023 }
1024
1025 /// The correction the Language Card promises for this violation, at the
1026 /// boundary that rejected the value: `spawn` for a task, `Shared` for a
1027 /// synchronized handle.
1028 pub fn help(&self, boundary: &str) -> String {
1029 if self.type_name == "Vector" {
1030 format!(
1031 "finish it as an array with `freeze()`, or copy it with `toArray()`, before {boundary}"
1032 )
1033 } else if self.type_name.contains('.') {
1034 format!(
1035 "`{}` is a host resource whose Host API schema declares it not task-safe; open one in the task that uses it rather than {boundary}",
1036 self.type_name
1037 )
1038 } else {
1039 "wrap mutable state in `Shared` or another synchronized type, or pass an immutable value"
1040 .to_string()
1041 }
1042 }
1043}
1044
1045/// The Language Card sentence every task-safety diagnostic quotes.
1046pub const TASK_SAFETY_RULE: &str = "Immutable task-safe values such as arrays may cross task boundaries. A vector cannot cross, even through `let`; finish it as an array or wrap mutable state in `Shared` or another synchronized type. Closures are task-safe only when every capture is.";
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051 use crate::schema::ResourceSchema;
1052 use crate::value::VectorStorage;
1053 use cove_diag::{FileId, Span};
1054 use cove_syntax::ast::Block;
1055
1056 /// The point of the type: a value that may cross a task boundary is a
1057 /// value a thread can own. If this ever stopped holding, `spawn` could
1058 /// not hand a body to a thread.
1059 #[test]
1060 fn a_transfer_can_be_owned_by_another_thread() {
1061 fn assert_send_sync<T: Send + Sync>() {}
1062 assert_send_sync::<Transfer>();
1063 }
1064
1065 #[test]
1066 fn converting_a_task_safe_value_and_back_preserves_it() {
1067 let value = Value(Repr::Array(
1068 vec![
1069 Value(Repr::Int(1)),
1070 Value(Repr::Str("two".into())),
1071 Value(Repr::Struct(Rc::new(StructValue {
1072 type_name: "test.Point".into(),
1073 fields: vec![("x".into(), Value(Repr::Int(3)))],
1074 opaque: false,
1075 }))),
1076 ]
1077 .into(),
1078 ));
1079 let crossed = Transfer::of(&value)
1080 .expect("an array of task-safe values may cross")
1081 .into_value();
1082 assert!(crossed.eq_value(&value), "{crossed} != {value}");
1083 }
1084
1085 #[test]
1086 fn a_vector_reached_through_a_struct_is_named_by_its_path() {
1087 let value = Value(Repr::Struct(Rc::new(StructValue {
1088 type_name: "test.Draft".into(),
1089 fields: vec![(
1090 "guests".into(),
1091 Value(Repr::Vector(VectorStorage::new(vec![Value(Repr::Int(1))]))),
1092 )],
1093 opaque: false,
1094 })));
1095 let found = Transfer::of(&value).expect_err("a vector may not cross");
1096 assert_eq!(found.path, ".guests");
1097 assert_eq!(found.type_name, "Vector");
1098 }
1099
1100 /// The one exception to the copy rule: both sides address one cell.
1101 #[test]
1102 fn a_shared_crosses_by_sharing_rather_than_by_copying() {
1103 let cell = SharedCell::new(Transfer::Int(1));
1104 let crossed = Transfer::of(&Value(Repr::Shared(cell.clone())))
1105 .expect("a `Shared` is task-safe")
1106 .into_value();
1107 match crossed {
1108 Value(Repr::Shared(other)) => assert!(Arc::ptr_eq(&cell, &other)),
1109 other => panic!("expected a `Shared`, found {other}"),
1110 }
1111 }
1112
1113 // ------------------------------------------- shapes a value crosses in
1114 //
1115 // The tests above cover a struct field directly. Everything below walks
1116 // the rest of the shapes `Transfer::convert` descends into — arrays,
1117 // enums, maps, trait objects, closures, and Host resource handles — and
1118 // pins both directions: a task-safe value of that shape crosses and
1119 // round-trips, and a `Vector` (or a non-task-safe resource handle)
1120 // nested in that shape is refused with a `path` that names how it was
1121 // reached.
1122
1123 /// A resource handle for a fictitious host, naming `module.Connection`,
1124 /// with its schema's `task_safe` set as the test needs.
1125 fn resource_handle(module: &str, task_safe: bool, id: u64) -> Arc<ResourceHandle> {
1126 let schema = ResourceSchema {
1127 name: "Connection",
1128 task_safe,
1129 operations: &[],
1130 };
1131 ResourceHandle::new(module, &schema, id)
1132 }
1133
1134 /// A closure with no body worth running: only its captures matter to
1135 /// `Transfer::convert`, so the body is the emptiest one the AST allows.
1136 fn closure_value(module: &str, captures: Vec<(&str, Value)>) -> Value {
1137 let span = Span::new(FileId(0), 0, 0);
1138 Value(Repr::Closure(Rc::new(Closure {
1139 is_async: false,
1140 arity: 0,
1141 body: ClosureBody::Tree {
1142 params: Vec::new(),
1143 block: Arc::new(Block {
1144 statements: Vec::new(),
1145 tail: None,
1146 span,
1147 }),
1148 decl: None,
1149 },
1150 module: module.into(),
1151 captures: captures
1152 .into_iter()
1153 .map(|(name, value)| (name.into(), value))
1154 .collect(),
1155 })))
1156 }
1157
1158 #[test]
1159 fn an_array_of_structs_crosses_and_round_trips() {
1160 let value = Value(Repr::Array(
1161 vec![
1162 Value(Repr::Struct(Rc::new(StructValue {
1163 type_name: "test.Point".into(),
1164 fields: vec![
1165 ("x".into(), Value(Repr::Int(1))),
1166 ("y".into(), Value(Repr::Int(2))),
1167 ],
1168 opaque: false,
1169 }))),
1170 Value(Repr::Struct(Rc::new(StructValue {
1171 type_name: "test.Point".into(),
1172 fields: vec![
1173 ("x".into(), Value(Repr::Int(3))),
1174 ("y".into(), Value(Repr::Int(4))),
1175 ],
1176 opaque: false,
1177 }))),
1178 ]
1179 .into(),
1180 ));
1181 let crossed = Transfer::of(&value)
1182 .expect("an array of structs built only from Ints is task-safe")
1183 .into_value();
1184 assert!(crossed.eq_value(&value), "{crossed} != {value}");
1185 }
1186
1187 /// "Immutable task-safe values such as arrays may cross task boundaries"
1188 /// — but an array is only as task-safe as what it holds. A vector
1189 /// nested two levels down, inside a struct inside the array, is still
1190 /// refused, and `Transfer::convert` builds the path by extending it once
1191 /// per level: `"{path}[{i}]"` for the array, then `"{path}.{name}"` for
1192 /// the struct field.
1193 #[test]
1194 fn an_array_of_structs_is_refused_for_the_one_vector_it_holds() {
1195 let value = Value(Repr::Array(
1196 vec![Value(Repr::Struct(Rc::new(StructValue {
1197 type_name: "test.Draft".into(),
1198 fields: vec![(
1199 "guests".into(),
1200 Value(Repr::Vector(VectorStorage::new(vec![Value(Repr::Int(1))]))),
1201 )],
1202 opaque: false,
1203 })))]
1204 .into(),
1205 ));
1206 let found = Transfer::of(&value).expect_err("a vector nested in an array may not cross");
1207 assert_eq!(found.path, "[0].guests");
1208 assert_eq!(found.type_name, "Vector");
1209 }
1210
1211 #[test]
1212 fn an_enum_payload_that_is_task_safe_crosses_and_round_trips() {
1213 let value = Value(Repr::Enum(Box::new(EnumValue {
1214 type_name: "test.Shape".into(),
1215 case: "Circle".into(),
1216 payload: crate::value::Payload::One(Value(Repr::Int(4))),
1217 })));
1218 let crossed = Transfer::of(&value)
1219 .expect("an enum payload of Ints is task-safe")
1220 .into_value();
1221 assert!(crossed.eq_value(&value), "{crossed} != {value}");
1222 }
1223
1224 /// An enum case's payload is walked exactly like a struct's fields, just
1225 /// with no field names to hang a path on: `Transfer::convert` names the
1226 /// case and the payload's position instead, `"{path}.{case}({i})"`.
1227 #[test]
1228 fn an_enum_payload_holding_a_vector_is_refused_by_its_case_and_index() {
1229 let value = Value(Repr::Enum(Box::new(EnumValue {
1230 type_name: "test.Shape".into(),
1231 case: "Wrap".into(),
1232 payload: crate::value::Payload::One(Value(Repr::Vector(
1233 VectorStorage::new(Vec::new()),
1234 ))),
1235 })));
1236 let found = Transfer::of(&value).expect_err("a vector in an enum payload may not cross");
1237 assert_eq!(found.path, ".Wrap(0)");
1238 assert_eq!(found.type_name, "Vector");
1239 }
1240
1241 #[test]
1242 fn a_map_of_task_safe_values_crosses_and_round_trips() {
1243 let value = Value(Repr::Map(Rc::new(BTreeMap::from([
1244 (MapKey::Str("a".to_string()), Value(Repr::Int(1))),
1245 (MapKey::Str("b".to_string()), Value(Repr::Int(2))),
1246 ]))));
1247 let crossed = Transfer::of(&value)
1248 .expect("a map of Ints is task-safe")
1249 .into_value();
1250 assert!(crossed.eq_value(&value), "{crossed} != {value}");
1251 }
1252
1253 #[test]
1254 fn a_map_value_holding_a_vector_is_refused_naming_the_key() {
1255 let value = Value(Repr::Map(Rc::new(BTreeMap::from([(
1256 MapKey::Str("widgets".to_string()),
1257 Value(Repr::Vector(VectorStorage::new(Vec::new()))),
1258 )]))));
1259 let found = Transfer::of(&value).expect_err("a vector held by a map entry may not cross");
1260 assert_eq!(found.path, "[widgets]");
1261 assert_eq!(found.type_name, "Vector");
1262 }
1263
1264 #[test]
1265 fn a_dyn_value_that_is_task_safe_crosses_keeping_its_trait_name() {
1266 let value = Value(Repr::Dyn(Rc::new(DynValue {
1267 trait_name: "render.Display".into(),
1268 value: Value(Repr::Str("hi".into())),
1269 })));
1270 let crossed = Transfer::of(&value)
1271 .expect("a `dyn Trait` wrapping a task-safe value is itself task-safe")
1272 .into_value();
1273 match crossed {
1274 Value(Repr::Dyn(d)) => {
1275 assert_eq!(&*d.trait_name, "render.Display");
1276 assert!(d.value.eq_value(&Value(Repr::Str("hi".into()))));
1277 }
1278 other => panic!("expected a `Dyn`, found {other}"),
1279 }
1280 }
1281
1282 /// "A trait object is task-safe exactly when the value it holds is: the
1283 /// wrapper adds a trait name, which is not state" — and `Transfer::convert`
1284 /// takes that literally about the path too, passing `path` through to the
1285 /// wrapped value *unchanged*. So a struct's `Vector` field is refused with
1286 /// exactly the path it would have outside the `Dyn`, with no `dyn` marker
1287 /// anywhere in it.
1288 #[test]
1289 fn a_dyn_wrapping_a_struct_with_a_vector_is_refused_at_the_fields_path() {
1290 let value = Value(Repr::Dyn(Rc::new(DynValue {
1291 trait_name: "render.Display".into(),
1292 value: Value(Repr::Struct(Rc::new(StructValue {
1293 type_name: "test.Draft".into(),
1294 fields: vec![(
1295 "guests".into(),
1296 Value(Repr::Vector(VectorStorage::new(Vec::new()))),
1297 )],
1298 opaque: false,
1299 }))),
1300 })));
1301 let found = Transfer::of(&value).expect_err("a vector inside a `Dyn` may not cross");
1302 assert_eq!(found.path, ".guests");
1303 assert_eq!(found.type_name, "Vector");
1304 }
1305
1306 #[test]
1307 fn a_closure_with_a_task_safe_capture_crosses_keeping_its_captures() {
1308 let value = closure_value(
1309 "test.mod",
1310 vec![
1311 ("count", Value(Repr::Int(1))),
1312 ("label", Value(Repr::Str("a".into()))),
1313 ],
1314 );
1315 let crossed = Transfer::of(&value)
1316 .expect("a closure whose captures are an Int and a String is task-safe")
1317 .into_value();
1318 match crossed {
1319 Value(Repr::Closure(closure)) => {
1320 assert_eq!(closure.captures.len(), 2);
1321 assert!(closure.captures[0].1.eq_value(&Value(Repr::Int(1))));
1322 assert!(closure.captures[1]
1323 .1
1324 .eq_value(&Value(Repr::Str("a".into()))));
1325 }
1326 other => panic!("expected a `Closure`, found {other}"),
1327 }
1328 }
1329
1330 /// "Closures are task-safe only when every capture is" — including a
1331 /// capture that is itself a closure, whose own captures are walked in
1332 /// turn. `Transfer::convert` writes `" -> "` between a capture's name and
1333 /// the name of whatever it captures next, so a vector reached two
1334 /// captures deep still names the whole chain that reaches it, not just
1335 /// the last step.
1336 #[test]
1337 fn a_closure_capturing_a_closure_whose_capture_holds_a_vector_is_refused_two_levels_deep() {
1338 let inner = closure_value(
1339 "test.mod",
1340 vec![(
1341 "state",
1342 Value(Repr::Struct(Rc::new(StructValue {
1343 type_name: "test.Draft".into(),
1344 fields: vec![(
1345 "guests".into(),
1346 Value(Repr::Vector(VectorStorage::new(Vec::new()))),
1347 )],
1348 opaque: false,
1349 }))),
1350 )],
1351 );
1352 let outer = closure_value("test.mod", vec![("handler", inner)]);
1353 let found =
1354 Transfer::of(&outer).expect_err("a vector captured two closures deep may not cross");
1355 assert_eq!(found.path, "handler -> state.guests");
1356 assert_eq!(found.type_name, "Vector");
1357 }
1358
1359 // ------------------------------------------------------ host resources
1360
1361 /// "Host resources declare task-safety in their Host API schema." A
1362 /// resource whose state the host keeps behind a lock says
1363 /// `task_safe: true`, and ADR 0013 says a handle is a name and nothing
1364 /// else, so it then crosses the way a string does: the same `Arc` both
1365 /// sides address, naming one resource.
1366 #[test]
1367 fn a_resource_handle_whose_schema_says_task_safe_crosses_naming_the_same_resource() {
1368 let handle = resource_handle("test", true, 7);
1369 let crossed = Transfer::of(&Value(Repr::Resource(handle.clone())))
1370 .expect("a resource whose schema says task-safe may cross")
1371 .into_value();
1372 match crossed {
1373 Value(Repr::Resource(other)) => assert!(
1374 Arc::ptr_eq(&handle, &other),
1375 "a handle crosses by sharing its `Arc`, so both sides should name one resource: {handle} and {other}"
1376 ),
1377 other => panic!("expected a `Resource`, found {other}"),
1378 }
1379 }
1380
1381 #[test]
1382 fn a_resource_handle_whose_schema_says_not_task_safe_is_refused() {
1383 let handle = resource_handle("test", false, 7);
1384 let found = Transfer::of(&Value(Repr::Resource(handle)))
1385 .expect_err("a resource whose schema says not task-safe may not cross");
1386 assert_eq!(found.type_name, "test.Connection");
1387 }
1388
1389 /// The same rule against a shipped schema rather than a fictitious one.
1390 /// ADR 0018 gives `files.Reader` `task_safe: false` because a reader is a
1391 /// position in a file, and two tasks taking turns at one position each
1392 /// receive some of the lines and neither receives the file. The refusal
1393 /// is what makes that a mistake the run reports rather than an
1394 /// interleaving no test can pin.
1395 #[test]
1396 fn a_files_reader_is_refused_at_a_task_boundary() {
1397 let handle = ResourceHandle::new("files", &cove_schema::hosts::FILES.resources[0], 1);
1398 let found = Transfer::of(&Value(Repr::Resource(handle)))
1399 .expect_err("a `files.Reader` may not cross a task boundary");
1400 assert_eq!(found.type_name, "files.Reader");
1401 assert_eq!(
1402 found.help("spawning"),
1403 "`files.Reader` is a host resource whose Host API schema declares it not task-safe; open one in the task that uses it rather than spawning"
1404 );
1405 }
1406
1407 /// The correction for a host resource is not "wrap it in `Shared`" — the
1408 /// host already decided this resource's state stays with the task that
1409 /// opened it — so `NotTaskSafe::help` reads a `.` in the type name, which
1410 /// is exactly how `Value::type_name` renders a resource's
1411 /// `module.Type`, and gives a different sentence than the generic one a
1412 /// struct or closure capture gets.
1413 #[test]
1414 fn not_task_safe_help_for_a_host_resource_names_its_schema() {
1415 let found = NotTaskSafe {
1416 path: String::new(),
1417 type_name: "database.Connection".to_string(),
1418 };
1419 assert_eq!(
1420 found.help("spawning"),
1421 "`database.Connection` is a host resource whose Host API schema declares it not task-safe; open one in the task that uses it rather than spawning"
1422 );
1423 }
1424
1425 // ------------------------------------------------------ shared cells
1426
1427 /// `Value::Shared(cell) => Ok(Transfer::Shared(cell.clone()))` asks
1428 /// nothing about what `cell` holds — unlike every other shape above,
1429 /// this arm does not walk. That is sound in the running language only
1430 /// because nothing reaches a `Shared` with an unchecked payload:
1431 /// `SharedCell::wrap` (`crates/cove-runtime/src/shared.rs`), called from
1432 /// the `Shared(value)` constructor in
1433 /// `crates/cove-runtime/src/builtins.rs`, runs `Transfer::of` on the
1434 /// payload before a cell is ever built, so a `Shared` this walk sees has
1435 /// already been vetted once, by a check that lives outside this file.
1436 /// This test reaches around that guard with `SharedCell::new` — a
1437 /// constructor ordinary Cove code cannot call — to pin what the walk
1438 /// itself actually does: it does not repeat the check.
1439 #[test]
1440 fn a_shared_crosses_without_rechecking_what_it_already_holds() {
1441 let handle = resource_handle("test", false, 1);
1442 Transfer::of(&Value(Repr::Resource(handle.clone())))
1443 .expect_err("a task-unsafe resource handle is refused on its own");
1444 let cell = SharedCell::new(Transfer::Resource(handle));
1445 let crossed = Transfer::of(&Value(Repr::Shared(cell.clone())))
1446 .expect("`Shared` crosses without walking its payload")
1447 .into_value();
1448 match crossed {
1449 Value(Repr::Shared(other)) => assert!(Arc::ptr_eq(&cell, &other)),
1450 other => panic!("expected a `Shared`, found {other}"),
1451 }
1452 }
1453
1454 // ------------------------------------------------------- subject()
1455
1456 #[test]
1457 fn not_task_safe_subject_names_the_type_alone_at_the_root() {
1458 let found = NotTaskSafe {
1459 path: String::new(),
1460 type_name: "Vector".to_string(),
1461 };
1462 assert_eq!(found.subject(), "a `Vector`");
1463 }
1464
1465 #[test]
1466 fn not_task_safe_subject_names_the_path_when_nested() {
1467 let found = NotTaskSafe {
1468 path: ".guests".to_string(),
1469 type_name: "Vector".to_string(),
1470 };
1471 assert_eq!(found.subject(), "a `Vector` in `guests`");
1472 }
1473}