Skip to main content

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}