Skip to main content

harn_vm/vm/
subtask.rs

1//! One owner for running a Harn child interpreter as a subtask.
2//!
3//! `spawn`, `parallel`, `parallel each`, `parallel settle`, the parallel
4//! stream fan-out, and `pool.submit` all create the same thing: a child
5//! interpreter that runs a closure concurrently with its parent. Each one
6//! needs the same three things, and each used to arrange them differently.
7//!
8//! 1. An isolated copy of the parent's ambient execution scope. It is captured
9//!    eagerly, while the parent's scope is still swapped in, so the subtask
10//!    carries the agent session, policies, and event attribution that were
11//!    live at the moment it was created.
12//! 2. The parent's pool registry, so `pool.*` inside the subtask reaches the
13//!    same pools instead of a fresh per-thread fallback.
14//! 3. A place on the runtime to run.
15//!
16//! [`prepare`] does the first two. [`spawn`] and [`spawn_into`] do the third.
17//! Nothing else in the crate decides where a child interpreter runs.
18//!
19//! # Placement
20//!
21//! [`SubtaskPlacement`] selects between the creating thread and the runtime's
22//! worker threads. Worker placement is the default now that every capability
23//! reachable from a child interpreter has an execution-scoped cross-thread
24//! owner. It gives CPU-bound fan-out real parallelism:
25//! a tight compute loop never yields, so pinned subtasks run one at a time no
26//! matter how many workers are idle.
27//!
28//! Both placements require the subtask future to be `Send + 'static`. That is
29//! deliberate. The bound is a property of the seam, not of the placement, so
30//! changing the default cannot turn a compiling program into a broken one.
31
32use std::future::Future;
33use std::sync::Arc;
34use std::task::{Context, Poll};
35
36use crate::orchestration::{scope_ambient, AmbientExecutionScope};
37use crate::stdlib::pool::{with_pool_registry_scope, PoolRegistry};
38use pin_project_lite::pin_project;
39
40pin_project! {
41    /// Proof that a future carries the ambient scope required at a runtime
42    /// thread boundary.
43    ///
44    /// The constructor stays private to this module. Callers can only obtain
45    /// one through [`prepare`], and the spawn functions only accept this type,
46    /// so a new child-interpreter path cannot accidentally bypass scope
47    /// capture while still compiling.
48    pub(crate) struct PreparedSubtask<F> {
49        #[pin]
50        inner: F,
51    }
52}
53
54impl<F: Future> Future for PreparedSubtask<F> {
55    type Output = F::Output;
56
57    fn poll(self: std::pin::Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Self::Output> {
58        self.project().inner.poll(context)
59    }
60}
61
62impl<F: Future> PreparedSubtask<F> {
63    /// Transform the output without discarding the proof carried by this
64    /// future. Executors use this to attach source-order indices before
65    /// inserting a branch into a `JoinSet`.
66    pub(crate) fn map_output<M, T>(self, map: M) -> PreparedSubtask<impl Future<Output = T>>
67    where
68        M: FnOnce(F::Output) -> T,
69    {
70        PreparedSubtask {
71            inner: async move { map(self.await) },
72        }
73    }
74}
75
76/// Where a subtask runs.
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
78pub enum SubtaskPlacement {
79    /// Run on the thread that created the subtask. Branches interleave at
80    /// await points and never migrate, preserving thread-affine capability and
81    /// embedding-host contracts.
82    CurrentThread,
83    /// Run on the runtime's worker threads. CPU-bound branches of one fan-out
84    /// then run at the same time on different cores. `current_thread` remains
85    /// an explicit compatibility mode for deliberately single-threaded hosts.
86    #[default]
87    Worker,
88}
89
90/// The environment variable that selects placement for a whole process.
91pub const PLACEMENT_ENV: &str = "HARN_VM_SUBTASK_PLACEMENT";
92/// Canonical values accepted for [`PLACEMENT_ENV`]. The environment registry
93/// consumes this same vocabulary, so startup validation and placement parsing
94/// cannot drift.
95pub const PLACEMENT_VALUES: &[&str] = &["worker", "current_thread"];
96
97/// An operator supplied a placement outside the runtime-owned vocabulary.
98#[derive(Clone, Debug, PartialEq, Eq)]
99pub struct SubtaskPlacementParseError {
100    value: String,
101}
102
103impl std::fmt::Display for SubtaskPlacementParseError {
104    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        write!(
106            formatter,
107            "invalid {PLACEMENT_ENV} value {:?}; expected one of {}",
108            self.value,
109            PLACEMENT_VALUES.join(", ")
110        )
111    }
112}
113
114impl std::error::Error for SubtaskPlacementParseError {}
115
116impl SubtaskPlacement {
117    /// Parse an operator-supplied placement name against the runtime-owned
118    /// closed vocabulary.
119    pub fn from_env_value(value: &str) -> Result<Self, SubtaskPlacementParseError> {
120        match value.trim().to_ascii_lowercase().as_str() {
121            "worker" => Ok(Self::Worker),
122            "current_thread" => Ok(Self::CurrentThread),
123            _ => Err(SubtaskPlacementParseError {
124                value: value.to_string(),
125            }),
126        }
127    }
128
129    fn name(self) -> &'static str {
130        match self {
131            Self::Worker => "worker",
132            Self::CurrentThread => "current_thread",
133        }
134    }
135}
136
137impl std::fmt::Display for SubtaskPlacement {
138    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        formatter.write_str(self.name())
140    }
141}
142
143/// The process-wide placement, read from the environment once.
144fn placement_from_environment() -> SubtaskPlacement {
145    static RESOLVED: std::sync::OnceLock<SubtaskPlacement> = std::sync::OnceLock::new();
146    *RESOLVED.get_or_init(|| {
147        let Ok(value) = std::env::var(PLACEMENT_ENV) else {
148            return SubtaskPlacement::default();
149        };
150        SubtaskPlacement::from_env_value(&value).unwrap_or_else(|error| panic!("{error}"))
151    })
152}
153
154thread_local! {
155    /// An execution-scoped placement override. It is captured by
156    /// [`AmbientExecutionScope`], so a nested subtask keeps the placement its
157    /// execution tree was started with instead of falling back to the process
158    /// default on a worker thread.
159    static SUBTASK_PLACEMENT_CONTEXT: std::cell::RefCell<Option<SubtaskPlacement>> =
160        const { std::cell::RefCell::new(None) };
161}
162
163/// Swap the execution-scoped placement override. Paired with
164/// [`AmbientExecutionScope`]'s per-poll swap.
165pub(crate) fn swap_subtask_placement_context(
166    next: Option<SubtaskPlacement>,
167) -> Option<SubtaskPlacement> {
168    SUBTASK_PLACEMENT_CONTEXT.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), next))
169}
170
171/// The placement new subtasks created on this thread will use.
172pub fn placement() -> SubtaskPlacement {
173    SUBTASK_PLACEMENT_CONTEXT
174        .with(|slot| *slot.borrow())
175        .unwrap_or_else(placement_from_environment)
176}
177
178/// Run `inner` with `placement` installed for every subtask its execution tree
179/// creates. The override rides [`AmbientExecutionScope`], so it survives the
180/// awaits and thread migrations inside `inner`.
181pub fn scope_placement<F: Future>(
182    placement: SubtaskPlacement,
183    inner: F,
184) -> impl Future<Output = F::Output> {
185    let mut scope = AmbientExecutionScope::capture_for_inline_subtask();
186    scope.set_subtask_placement(Some(placement));
187    scope_ambient(scope, inner)
188}
189
190/// Wrap a child-interpreter body so it carries its parent's ambient execution
191/// scope and pool registry.
192///
193/// Capture happens here, synchronously, while the creating task's scope is
194/// still swapped in. A subtask created from inside a fan-out worker whose own
195/// scope has already been swapped out would otherwise read an empty or sibling
196/// context: subtasks are independent runtime tasks, not nested inside the
197/// parent's poll.
198pub(crate) fn prepare<F: Future>(
199    registry: Arc<PoolRegistry>,
200    future: F,
201) -> PreparedSubtask<impl Future<Output = F::Output>> {
202    PreparedSubtask {
203        inner: scope_ambient(
204            AmbientExecutionScope::capture_for_inline_subtask(),
205            with_pool_registry_scope(registry, future),
206        ),
207    }
208}
209
210/// Put a prepared subtask on the runtime.
211pub(crate) fn spawn<F>(future: PreparedSubtask<F>) -> tokio::task::JoinHandle<F::Output>
212where
213    F: Future + Send + 'static,
214    F::Output: Send + 'static,
215{
216    match placement() {
217        SubtaskPlacement::Worker => tokio::spawn(future),
218        SubtaskPlacement::CurrentThread => tokio::task::spawn_local(future),
219    }
220}
221
222/// Put a prepared subtask on the runtime as a member of `set`.
223pub(crate) fn spawn_into<F>(
224    set: &mut tokio::task::JoinSet<F::Output>,
225    future: PreparedSubtask<F>,
226) -> tokio::task::AbortHandle
227where
228    F: Future + Send + 'static,
229    F::Output: Send + 'static,
230{
231    match placement() {
232        SubtaskPlacement::Worker => set.spawn(future),
233        SubtaskPlacement::CurrentThread => {
234            // A current-thread execution tree is the deterministic placement.
235            // Give each child its first poll in source order before admitting
236            // the next child; Tokio's local ready queue does not promise the
237            // same order across separately constructed runtimes. A pending
238            // future is polled again immediately by the spawned task, replacing
239            // this noop waker with its real scheduler waker.
240            let mut future = Box::pin(future);
241            let mut context = Context::from_waker(std::task::Waker::noop());
242            match future.as_mut().poll(&mut context) {
243                Poll::Ready(value) => set.spawn_local(async move { value }),
244                Poll::Pending => set.spawn_local(future),
245            }
246        }
247    }
248}
249
250/// Prepare and spawn in one step, for callers that do not hold the future
251/// between the two.
252pub(crate) fn spawn_child<F>(
253    registry: Arc<PoolRegistry>,
254    future: F,
255) -> tokio::task::JoinHandle<F::Output>
256where
257    F: Future + Send + 'static,
258    F::Output: Send + 'static,
259{
260    spawn(prepare(registry, future))
261}
262
263/// Spawn a long-lived child that inherits execution policy but owns an
264/// independent session lifecycle.
265pub(crate) fn spawn_inherited_child<F>(
266    registry: Arc<PoolRegistry>,
267    future: F,
268) -> tokio::task::JoinHandle<F::Output>
269where
270    F: Future + Send + 'static,
271    F::Output: Send + 'static,
272{
273    spawn(PreparedSubtask {
274        inner: scope_ambient(
275            AmbientExecutionScope::capture_inherited(),
276            with_pool_registry_scope(registry, future),
277        ),
278    })
279}
280
281#[cfg(test)]
282#[path = "subtask/cross_thread_tests.rs"]
283mod cross_thread_tests;
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn placement_names_round_trip() {
291        assert_eq!(
292            SubtaskPlacement::from_env_value("worker"),
293            Ok(SubtaskPlacement::Worker)
294        );
295        assert_eq!(
296            SubtaskPlacement::from_env_value(" CURRENT_THREAD "),
297            Ok(SubtaskPlacement::CurrentThread)
298        );
299        assert_eq!(
300            SubtaskPlacement::from_env_value("sideways")
301                .expect_err("invalid placement must not become an absent override")
302                .to_string(),
303            "invalid HARN_VM_SUBTASK_PLACEMENT value \"sideways\"; expected one of worker, current_thread"
304        );
305        assert_eq!(SubtaskPlacement::Worker.to_string(), "worker");
306    }
307
308    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
309    async fn scoped_placement_reaches_the_spawn_seam() {
310        assert_eq!(placement(), SubtaskPlacement::Worker);
311        let observed =
312            scope_placement(SubtaskPlacement::CurrentThread, async { placement() }).await;
313        assert_eq!(observed, SubtaskPlacement::CurrentThread);
314        assert_eq!(placement(), SubtaskPlacement::Worker);
315    }
316
317    #[test]
318    fn placement_selects_the_executor_thread() {
319        fn observed_thread(
320            runtime: &tokio::runtime::Runtime,
321            placement: SubtaskPlacement,
322        ) -> std::thread::ThreadId {
323            runtime.block_on(async {
324                tokio::task::LocalSet::new()
325                    .run_until(scope_placement(placement, async {
326                        spawn(PreparedSubtask {
327                            inner: async { std::thread::current().id() },
328                        })
329                        .await
330                        .expect("subtask completes")
331                    }))
332                    .await
333            })
334        }
335
336        let runtime = tokio::runtime::Builder::new_multi_thread()
337            .worker_threads(2)
338            .enable_all()
339            .build()
340            .expect("test runtime");
341        let creating_thread = std::thread::current().id();
342
343        assert_ne!(
344            observed_thread(&runtime, SubtaskPlacement::Worker),
345            creating_thread
346        );
347        assert_eq!(
348            observed_thread(&runtime, SubtaskPlacement::CurrentThread),
349            creating_thread
350        );
351    }
352}