Skip to main content

relux_runtime/effect/
mod.rs

1pub mod registry;
2
3use std::collections::HashMap;
4use std::collections::HashSet;
5use std::sync::Arc;
6
7use tokio::sync::Mutex as TokioMutex;
8use tokio::sync::watch;
9
10use futures::future::join_all;
11
12use crate::RuntimeContext;
13use crate::cancel::CancelToken;
14use crate::effect::registry::AcquiredEffect;
15use crate::effect::registry::EffectGuard;
16use crate::effect::registry::EffectHandle;
17use crate::effect::registry::EffectInstanceKey;
18use crate::effect::registry::EffectRegistry;
19use crate::effect::registry::EffectSlot;
20use crate::effect::registry::ExportedEffect;
21use crate::effect::registry::ReleaseOutcome;
22use crate::effect::registry::ShellInstanceKey;
23use crate::effect::registry::ShellMap;
24use crate::effect::registry::VarMap;
25use crate::observe::structured::MatchContext;
26use crate::observe::structured::SpanId;
27use crate::observe::structured::SpanKind;
28use crate::report::result::ExecError;
29use crate::report::result::Failure;
30use crate::report::result::FailureContext;
31use crate::report::result::pure_eval_failure;
32use crate::vm::Vm;
33use crate::vm::context::ExecutionContext;
34use crate::vm::context::Scope;
35use crate::vm::context::ShellState;
36use relux_core::pure::Env;
37use relux_core::pure::LayeredEnv;
38use relux_core::pure::LayeredEnvSource;
39use relux_core::pure::VarScope;
40use relux_ir::IrCleanupBlock;
41use relux_ir::IrEffectItem;
42use relux_ir::IrEffectStart;
43use relux_ir::IrNode;
44
45// --- Warning / CleanupSource -----------------------------
46
47#[derive(Debug, Clone)]
48pub enum CleanupSource {
49    Test,
50    Effect { name: String },
51}
52
53#[derive(Debug, Clone)]
54pub enum Warning {
55    CleanupFailed {
56        source: CleanupSource,
57        failure: ExecError,
58    },
59}
60
61// --- Start scheduling -----------------------------------
62
63/// Completion signal a start publishes for its dependents. Sent over a
64/// per-start `watch` channel, which is multi-consumer and race-free: a
65/// dependent that subscribes AFTER its dependency published still
66/// observes the current value, so the publish-before-await window cannot
67/// strand a waiter. Modeled as an enum so the illegal "succeeded but no
68/// vars" / "failed but has vars" states are unrepresentable.
69#[derive(Clone)]
70enum StartSignal {
71    /// The dependency is ready; carries its exposed vars (keyed by
72    /// exposed name) for injection into dependents under `Alias.var`.
73    Ready(VarMap),
74    /// The dependency failed (or short-circuited on its own failed dep).
75    Failed,
76}
77
78/// Per-start acquire outcome. Each driver future evaluates to one of
79/// these; `join_all` collects them in input order for partitioning.
80enum StartOutcome {
81    /// The start acquired successfully.
82    Ready {
83        export: ExportedEffect,
84        guard: EffectGuard,
85    },
86    /// The start hit a real overlay-eval or acquire error.
87    Failed(ExecError),
88    /// The start short-circuited because a dependency failed; it never
89    /// evaluated its overlay. Carries no error of its own - the root
90    /// cause is a `Failed` elsewhere in the same batch.
91    DepFailed,
92}
93
94// --- EffectManager ---------------------------------------
95
96pub struct EffectManager {
97    registry: Arc<EffectRegistry>,
98    pub(crate) rt_ctx: RuntimeContext,
99    /// Guards for the test's direct effect acquires (`start E as a`
100    /// at the top of a test). Drained by `cleanup_all`. Per-test by
101    /// construction: each test instantiates its own `EffectManager`.
102    top_level_guards: TokioMutex<Vec<EffectGuard>>,
103    /// Root anchor for every `EffectCleanup` span emitted during this
104    /// test's lifetime - happy-path teardown, mid-setup rollback, and
105    /// `try_guards!`-driven partial teardown alike. Per-test by
106    /// construction (same lifetime as `top_level_guards`). Threading
107    /// this from `lib.rs` once at construction lets failure paths
108    /// reach `test_span` directly without having to thread it through
109    /// every recursive call alongside the setup-hierarchy parent.
110    test_span: SpanId,
111}
112
113impl EffectManager {
114    pub fn new(registry: Arc<EffectRegistry>, rt_ctx: RuntimeContext, test_span: SpanId) -> Self {
115        Self {
116            registry,
117            rt_ctx,
118            top_level_guards: TokioMutex::new(Vec::new()),
119            test_span,
120        }
121    }
122
123    /// Acquire all starts, honoring the wait-set edges enrichment
124    /// derived from each start's overlay (`IrEffectStart::deps()`).
125    /// `caller_vars` contains the caller's accumulated variable scope,
126    /// allowing overlay expressions to reference the caller's `let` bindings.
127    /// `caller_env` is the layered environment visible to the caller.
128    /// Returns one `(ExportedEffect, EffectGuard)` per start declaration,
129    /// in input order.
130    ///
131    /// Scheduling: each start runs as its own future. A start whose
132    /// `deps()` is non-empty first awaits those sibling starts' completion
133    /// signals (published over per-start `watch` channels); the ready
134    /// deps' EXPOSED VARS are injected into the start's scope under
135    /// `Alias.var` keys BEFORE its overlay is evaluated, so the overlay's
136    /// `QualifiedVar` refs resolve. Independent starts (empty `deps()`)
137    /// still bootstrap concurrently, exactly as before. Enrichment proved
138    /// the dep graph acyclic, so the wait-set awaits cannot deadlock.
139    ///
140    /// The slot lock in `acquire` (see `registry::EffectSlot`) serialises
141    /// bootstrap-vs-reuse for the same dedup key, so parallel acquires of
142    /// overlapping keys are safe - one bootstraps, the others wait on the
143    /// slot's `Notify`. Independent effects (different keys) bootstrap
144    /// truly in parallel. Overlay evaluation runs inside each start's
145    /// future (concurrently): a start's scope is fully materialized
146    /// (caller scope + resolved dep vars) BEFORE its pure overlay eval
147    /// runs, and pure eval has no cross-start side effects, so concurrent
148    /// evaluation cannot change any overlay's value or its derived dedup
149    /// key.
150    ///
151    /// Rollback stays all-or-nothing, generalized along dep edges: a
152    /// failed dep fails its dependents (recorded as `DepFailed`), and on
153    /// ANY failure every successful acquire is released concurrently
154    /// before the first root error propagates.
155    #[allow(clippy::type_complexity)]
156    pub fn instantiate<'a>(
157        &'a self,
158        starts: &'a [IrEffectStart],
159        caller_vars: &'a VarScope,
160        caller_env: &'a Arc<LayeredEnv>,
161        parent_span: SpanId,
162        caller_captures: &'a std::collections::HashMap<String, String>,
163    ) -> std::pin::Pin<
164        Box<
165            dyn std::future::Future<Output = Result<Vec<(ExportedEffect, EffectGuard)>, ExecError>>
166                + Send
167                + 'a,
168        >,
169    > {
170        Box::pin(async move {
171            let n = starts.len();
172
173            // One `watch` channel per start carries that start's
174            // completion signal to its dependents. `watch` is
175            // multi-consumer and race-free: a dependent that subscribes
176            // AFTER its dep published still sees the current value, so a
177            // publish-before-await race cannot strand a waiter.
178            let mut senders: Vec<watch::Sender<Option<StartSignal>>> = Vec::with_capacity(n);
179            let mut receivers: Vec<watch::Receiver<Option<StartSignal>>> = Vec::with_capacity(n);
180            for _ in 0..n {
181                let (tx, rx) = watch::channel(None);
182                senders.push(tx);
183                receivers.push(rx);
184            }
185
186            let senders_ref = &senders;
187            let receivers_ref = &receivers;
188
189            // One driving future per start. A start awaits its `deps()`
190            // wait-set, then evaluates its overlay against a scope
191            // augmented with the ready deps' exposed vars, then acquires.
192            // Each future EVALUATES TO its `StartOutcome`; `join_all`
193            // returns them in input order, so no side-channel collector is
194            // needed. A start that short-circuits on dep failure STILL
195            // publishes a `Failed` signal so ITS dependents unblock instead
196            // of hanging.
197            let drivers = starts.iter().enumerate().map(|(i, start)| async move {
198                // 1. Await every dependency's completion signal.
199                //    Enrichment proved the graph acyclic, so this cannot
200                //    deadlock.
201                let mut dep_failed = false;
202                let mut dep_vars: Vec<(usize, VarMap)> = Vec::with_capacity(start.deps().len());
203                for &j in start.deps() {
204                    let mut rx = receivers_ref[j].clone();
205                    match rx.wait_for(Option::is_some).await {
206                        Ok(v) => match v.clone().expect("wait_for predicate guarantees Some") {
207                            StartSignal::Ready(vars) => dep_vars.push((j, vars)),
208                            StartSignal::Failed => dep_failed = true,
209                        },
210                        // Unreachable in practice: senders live until this
211                        // batch's `join_all` returns. Treat a dropped
212                        // sender as a failed dep rather than panicking.
213                        Err(_) => dep_failed = true,
214                    }
215                }
216
217                // 2. A failed dependency means this start can never
218                //    evaluate its overlay. Publish a failure signal (so
219                //    transitive dependents short-circuit too) and yield a
220                //    `DepFailed` outcome without acquiring.
221                if dep_failed {
222                    let _ = senders_ref[i].send(Some(StartSignal::Failed));
223                    return StartOutcome::DepFailed;
224                }
225
226                // 3. Build this start's scope: the caller's scope
227                //    augmented with each ready dep's exposed vars, injected
228                //    under `Alias.var` keys - the exact flat-key shape the
229                //    overlay's `QualifiedVar` refs resolve against (and the
230                //    same shape `bootstrap_effect` injects for an effect's
231                //    own deps). Only aliased deps contribute; deps only
232                //    ever point at aliased starts by construction. With no
233                //    deps we reuse the caller's scope untouched, preserving
234                //    today's independent-start path (no clone).
235                let augmented;
236                let scope: &VarScope = if dep_vars.is_empty() {
237                    caller_vars
238                } else {
239                    let mut aug = caller_vars.clone();
240                    for (j, vars) in &dep_vars {
241                        if let Some(alias) = starts[*j].alias() {
242                            for (var_name, value) in vars {
243                                aug.insert(format!("{alias}.{var_name}"), value.clone());
244                            }
245                        }
246                    }
247                    augmented = aug;
248                    &augmented
249                };
250
251                // 4. Evaluate the overlay against the augmented scope and
252                //    derive the dedup key.
253                let evaluated = match self
254                    .eval_overlay(start, scope, caller_env, parent_span, caller_captures)
255                    .await
256                {
257                    Ok(e) => e,
258                    Err(err) => {
259                        let _ = senders_ref[i].send(Some(StartSignal::Failed));
260                        return StartOutcome::Failed(err);
261                    }
262                };
263                let expect_names: Vec<&str> = self
264                    .rt_ctx
265                    .tables
266                    .effects
267                    .get(start.effect())
268                    .and_then(|r| r.as_ref().ok())
269                    .map(|eff| eff.expects().iter().map(|e| e.name()).collect())
270                    .unwrap_or_default();
271                let key = EffectInstanceKey::from_expects(
272                    start.effect().clone(),
273                    &expect_names,
274                    &evaluated,
275                );
276
277                // 5. Acquire, then publish this start's completion signal:
278                //    its exposed vars (for dependents to inject). On failure
279                //    publish `Failed`.
280                match self
281                    .acquire(&key, start, scope, caller_env, evaluated, parent_span)
282                    .await
283                {
284                    Ok((acquired, guard)) => {
285                        let _ =
286                            senders_ref[i].send(Some(StartSignal::Ready(acquired.vars.clone())));
287                        StartOutcome::Ready {
288                            export: ExportedEffect {
289                                key,
290                                shells: acquired.shells,
291                                vars: acquired.vars,
292                            },
293                            guard,
294                        }
295                    }
296                    Err(err) => {
297                        let _ = senders_ref[i].send(Some(StartSignal::Failed));
298                        StartOutcome::Failed(err)
299                    }
300                }
301            });
302
303            // Drive ALL start futures to completion - never drop an
304            // in-flight `acquire`, which would strand its slot in
305            // `Loading` and stall future acquirers. `join_all` preserves
306            // input order, so `outcomes` is already position-aligned.
307            let outcomes: Vec<StartOutcome> = join_all(drivers).await;
308
309            // Partition in input order. On ANY failure, release every
310            // successful acquire (concurrently) and propagate the first
311            // root error. Cleanup spans anchor under `self.test_span`,
312            // never `parent_span` - on the recursive path `parent_span`
313            // is the caller effect's open `EffectSetup` span, and nesting
314            // cleanups inside it violates the invariant from 85eef51.
315            let mut results: Vec<(ExportedEffect, EffectGuard)> = Vec::with_capacity(n);
316            let mut first_error: Option<ExecError> = None;
317            let mut failed = false;
318            for outcome in outcomes {
319                match outcome {
320                    StartOutcome::Ready { export, guard } => results.push((export, guard)),
321                    StartOutcome::Failed(err) => {
322                        failed = true;
323                        if first_error.is_none() {
324                            first_error = Some(err);
325                        }
326                    }
327                    // A `DepFailed` start's root cause is a `Failed`
328                    // elsewhere in this same batch (a dep can only fail
329                    // because it - or transitively ITS dep - hit a real
330                    // acquire/overlay error), so `first_error` is always
331                    // populated whenever any `DepFailed` is present.
332                    StartOutcome::DepFailed => failed = true,
333                }
334            }
335
336            if failed {
337                // `.expect` guards the (unreachable) case of a failed
338                // batch with no root error: panicking beats silently
339                // returning a truncated success `Vec` that a caller's
340                // `zip(starts)` would misalign.
341                let failure = first_error
342                    .expect("a failed start batch always carries a root acquire/overlay error");
343                let releases = results
344                    .into_iter()
345                    .map(|(_export, guard)| self.release_and_teardown(guard, self.test_span));
346                let _ = join_all(releases).await;
347                return Err(failure);
348            }
349
350            Ok(results)
351        })
352    }
353
354    /// Public top-level entry point used by the test runner. Acquires every
355    /// `start` in `starts`, stashes the resulting guards on the
356    /// `EffectManager` so `cleanup_all` can drain them, and returns the
357    /// shells/vars exports for the caller's shell map.
358    pub async fn instantiate_top_level(
359        &self,
360        starts: &[IrEffectStart],
361        caller_vars: &VarScope,
362        caller_env: &Arc<LayeredEnv>,
363        caller_captures: &std::collections::HashMap<String, String>,
364    ) -> Result<Vec<ExportedEffect>, ExecError> {
365        let pairs = self
366            .instantiate(
367                starts,
368                caller_vars,
369                caller_env,
370                self.test_span,
371                caller_captures,
372            )
373            .await?;
374        let mut top = self.top_level_guards.lock().await;
375        let mut exported = Vec::with_capacity(pairs.len());
376        for (ex, guard) in pairs {
377            top.push(guard);
378            exported.push(ex);
379        }
380        Ok(exported)
381    }
382
383    /// Drain the test's top-level guards and release each concurrently.
384    /// The slot mutex + refcount guarantee that for each dedup'd slot,
385    /// exactly one releaser sees `refcount == 0` and runs the cleanup
386    /// body; other releasers return `None` and short-circuit.
387    ///
388    /// Every `EffectCleanup` span opened here is parented under
389    /// `self.test_span`. Cleanups are operationally test-level activity
390    /// (scheduled at test teardown), and the `EffectSetup` span has
391    /// long since closed.
392    pub async fn cleanup_all(&self) -> Vec<Warning> {
393        let guards: Vec<EffectGuard> = std::mem::take(&mut *self.top_level_guards.lock().await);
394        let futures = guards
395            .into_iter()
396            .map(|g| self.release_and_teardown(g, self.test_span));
397        join_all(futures).await.into_iter().flatten().collect()
398    }
399
400    async fn acquire(
401        &self,
402        key: &EffectInstanceKey,
403        start: &IrEffectStart,
404        caller_vars: &VarScope,
405        caller_env: &Arc<LayeredEnv>,
406        evaluated_overlay: Env,
407        parent_span: SpanId,
408    ) -> Result<(AcquiredEffect, EffectGuard), ExecError> {
409        let slot = self.registry.slot(key);
410        // The slot lock is held only across state inspection and transitions
411        // (`Empty -> Loading`, `Loading -> Ready/Failed`). `bootstrap_effect`
412        // runs WITHOUT the slot lock, so concurrent acquirers that hit
413        // `Loading` can wait without blocking the bootstrap task. Per-test
414        // serial use means this lock-free window is dead code today, but
415        // removing it would re-introduce a deadlock surface if instantiation
416        // ever runs concurrently.
417        let mut evaluated_overlay = Some(evaluated_overlay);
418        loop {
419            let mut guard = slot.lock().await;
420            match &mut *guard {
421                EffectSlot::Ready { refcount, handle } => {
422                    *refcount += 1;
423                    let acquired = AcquiredEffect {
424                        shells: handle.exposed_shells(),
425                        vars: handle.exposed_vars.clone(),
426                    };
427                    let marker = handle.marker.clone();
428                    drop(guard);
429
430                    // Emit a zero-duration reuse span under the caller's
431                    // parent so the dedup hit is visible in the viewer.
432                    // The marker matches the bootstrap span's marker -
433                    // the viewer hops back by marker on pill click.
434                    let overlay = evaluated_overlay
435                        .take()
436                        .expect("Ready slot reachable only once per acquire");
437                    let reuse_span = self.rt_ctx.log.open_span(
438                        SpanKind::EffectSetup {
439                            effect: start.effect().name.to_string(),
440                            overlay: Self::evaluated_overlay_pairs(&overlay),
441                            alias: start.alias().map(String::from),
442                            dep_sources: relux_ir::overlay_dep_sources(start),
443                            marker,
444                            is_reuse: true,
445                        },
446                        Some(parent_span),
447                        Some(start.span()),
448                    );
449                    reuse_span.close();
450                    return Ok((acquired, EffectGuard::new(slot.clone())));
451                }
452                EffectSlot::Failed(failure) => return Err(failure.clone()),
453                EffectSlot::Loading(notify) => {
454                    let notify = notify.clone();
455                    drop(guard);
456                    notify.notified().await;
457                    // Slot is now Ready, Failed, or (rarely, on bootstrap
458                    // panic in another task) still Loading. Loop and re-check.
459                    continue;
460                }
461                EffectSlot::Empty => {
462                    let notify = Arc::new(tokio::sync::Notify::new());
463                    *guard = EffectSlot::Loading(notify.clone());
464                    drop(guard);
465
466                    // `Some` on the first iteration; the loop only continues
467                    // through `Loading`, which doesn't consume the overlay.
468                    let overlay = evaluated_overlay
469                        .take()
470                        .expect("Empty slot reachable only once per acquire");
471                    let bootstrap_result = self
472                        .bootstrap_effect(key, start, caller_vars, caller_env, overlay, parent_span)
473                        .await;
474
475                    let mut guard = slot.lock().await;
476                    match bootstrap_result {
477                        Ok(handle) => {
478                            let acquired = AcquiredEffect {
479                                shells: handle.exposed_shells(),
480                                vars: handle.exposed_vars.clone(),
481                            };
482                            *guard = EffectSlot::Ready {
483                                refcount: 1,
484                                handle: Box::new(handle),
485                            };
486                            drop(guard);
487                            notify.notify_waiters();
488                            return Ok((acquired, EffectGuard::new(slot.clone())));
489                        }
490                        Err(failure) => {
491                            self.rt_ctx.log.emit_error(
492                                parent_span,
493                                "",
494                                "",
495                                &failure.summary(),
496                                None,
497                            );
498                            *guard = EffectSlot::Failed(failure.clone());
499                            drop(guard);
500                            notify.notify_waiters();
501                            return Err(failure);
502                        }
503                    }
504                }
505            }
506        }
507    }
508
509    async fn bootstrap_effect(
510        &self,
511        key: &EffectInstanceKey,
512        start: &IrEffectStart,
513        _caller_vars: &VarScope,
514        caller_env: &Arc<LayeredEnv>,
515        evaluated_overlay: Env,
516        parent_span: SpanId,
517    ) -> Result<EffectHandle, ExecError> {
518        let marker = key.marker();
519        let overlay_pairs = Self::evaluated_overlay_pairs(&evaluated_overlay);
520        let setup_span = self.rt_ctx.log.open_span(
521            SpanKind::EffectSetup {
522                effect: start.effect().name.to_string(),
523                overlay: overlay_pairs,
524                alias: start.alias().map(String::from),
525                dep_sources: relux_ir::overlay_dep_sources(start),
526                marker: marker.clone(),
527                is_reuse: false,
528            },
529            Some(parent_span),
530            Some(start.span()),
531        );
532        self.rt_ctx.log.push_effect_setup(&start.effect().name.0);
533
534        let effect_result = self
535            .rt_ctx
536            .tables
537            .effects
538            .get(start.effect())
539            .ok_or_else(|| Failure::Runtime {
540                message: format!("effect {:?} not found in table", start.effect()),
541                span: start.effect_span().clone(),
542                shell: None,
543                context: FailureContext::pre_vm_with_span(setup_span.id()),
544            })?;
545        let effect = effect_result.as_ref().map_err(|e| Failure::Runtime {
546            message: format!("effect resolution failed: {e:?}"),
547            span: start.effect_span().clone(),
548            shell: None,
549            context: FailureContext::pre_vm_with_span(setup_span.id()),
550        })?;
551        let setup_span_id = setup_span.id();
552
553        // 1. Create layered env from pre-evaluated overlay (inherits caller's env)
554        let effect_env = Arc::new(LayeredEnv::child_with_source(
555            caller_env.clone(),
556            evaluated_overlay,
557            LayeredEnvSource::EffectOverlay(marker.clone()),
558        ));
559
560        // 2. Create effect scope
561        let scope = Scope::Effect {
562            name: effect.name().name().to_string(),
563            vars: Arc::new(TokioMutex::new(VarScope::new())),
564            _timeout: None,
565            env: effect_env.clone(),
566        };
567
568        // 3. Evaluate effect-level preamble (lets + pure-matches) into scope
569        //    (parser enforces these come before starts). `body_captures`
570        //    is hoisted across the whole preamble so a regex pure-match's
571        //    `$n` captures flow into later lets, pure-matches, and the
572        //    sub-dependency overlays instantiated in step 4.
573        let mut body_captures: HashMap<String, String> = HashMap::new();
574        let ec = MatchContext::EffectPreamble {
575            name: start.effect().name.to_string(),
576        };
577        for item in effect.body() {
578            match item {
579                IrEffectItem::Let { stmt, span } => {
580                    crate::preamble::eval_preamble_let(
581                        &self.rt_ctx.log,
582                        &effect_env,
583                        &self.rt_ctx.tables.pure_fns,
584                        &scope,
585                        setup_span_id,
586                        &ec,
587                        stmt,
588                        span,
589                        &body_captures,
590                    )
591                    .await?;
592                }
593                IrEffectItem::PureMatch {
594                    lhs,
595                    pattern,
596                    is_regex,
597                    span,
598                } => {
599                    crate::preamble::eval_preamble_pure_match(
600                        &self.rt_ctx.log,
601                        &effect_env,
602                        &self.rt_ctx.tables.pure_fns,
603                        &scope,
604                        setup_span_id,
605                        &ec,
606                        lhs,
607                        pattern,
608                        *is_regex,
609                        span,
610                        &mut body_captures,
611                    )
612                    .await?;
613                }
614                // Non-preamble items run in the body walk below.
615                IrEffectItem::Comment { .. }
616                | IrEffectItem::Expect { .. }
617                | IrEffectItem::Start { .. }
618                | IrEffectItem::Expose { .. }
619                | IrEffectItem::Shell { .. }
620                | IrEffectItem::Cleanup { .. } => {}
621            }
622        }
623
624        // 4. Recursively instantiate sub-dependencies. Each pair = (export, guard).
625        //    The `?` below is safe without guard release: `dep_guards` hasn't
626        //    been populated yet, and `instantiate`'s own partial-batch
627        //    rollback handles anything it acquired before failing. The
628        //    effect's own cleanup body is also not invoked here - it can
629        //    reference dep-exposed vars, and at this point deps don't exist,
630        //    so there is nothing for cleanup to act on.
631        let effect_vars = scope.vars().lock().await.clone();
632        let exported_deps = self
633            .instantiate(
634                effect.starts(),
635                &effect_vars,
636                &effect_env,
637                setup_span_id,
638                &body_captures,
639            )
640            .await?;
641
642        // From here on, `dep_guards` accumulates the guards for the
643        // successfully-instantiated deps. Every fallible step between this
644        // point and the final `Ok(EffectHandle { ... dep_guards ... })` is
645        // wrapped in `try_guards!`, which runs this effect's own cleanup
646        // body (if declared) and releases the accumulated dep guards via
647        // `run_effect_cleanup` before propagating the error - matching the
648        // success-path teardown order (effect's own cleanup before its
649        // deps').
650
651        let mut dep_shells: HashMap<String, ShellMap> = HashMap::new();
652        let mut dep_vars: HashMap<String, VarMap> = HashMap::new();
653        let mut dep_guards: Vec<EffectGuard> = Vec::with_capacity(exported_deps.len());
654        let mut alias_to_effect_name: HashMap<String, String> = HashMap::new();
655        for (sub_start, (exported, guard)) in effect.starts().iter().zip(exported_deps) {
656            dep_guards.push(guard);
657            if let Some(alias) = sub_start.alias() {
658                dep_shells.insert(alias.to_string(), exported.shells);
659                dep_vars.insert(alias.to_string(), exported.vars);
660                alias_to_effect_name.insert(alias.to_string(), sub_start.effect().name.0.clone());
661            }
662        }
663
664        // Pre-extract the cleanup block so it is available to `try_guards!`
665        // failures that fire inside the body walk below (a shell-block
666        // statement that fails before the body walk reaches the
667        // `IrEffectItem::Cleanup` arm). The body walk no longer captures
668        // this - see step 6.
669        let cleanup_block: Option<IrCleanupBlock> = effect.body().iter().find_map(|item| {
670            if let IrEffectItem::Cleanup { block, .. } = item {
671                Some(block.clone())
672            } else {
673                None
674            }
675        });
676
677        // 5b. Reset imported VMs into this scope's POV.
678        let mut reset_seen = HashSet::new();
679        for (alias, shells_map) in &dep_shells {
680            let source_effect_name = alias_to_effect_name.get(alias).cloned();
681            for (shell_local_name, vm_arc) in shells_map.iter() {
682                let ptr = Arc::as_ptr(vm_arc) as usize;
683                if reset_seen.insert(ptr) {
684                    vm_arc.lock().await.reset_for_export(
685                        scope.clone(),
686                        Some(alias.clone()),
687                        source_effect_name.clone(),
688                        shell_local_name.clone(),
689                    );
690                }
691            }
692        }
693
694        // Build local shells map, pre-populated with aliased dependency shells.
695        // When a dependency is aliased (e.g. `start SetupDb as db`), its exported
696        // shells are accessible by alias in the effect body (`shell db { ... }`
697        // reuses the dependency's shell).
698        let mut shells: HashMap<String, Arc<TokioMutex<Vm>>> = HashMap::new();
699        for (alias, dep_exported) in &dep_shells {
700            if dep_exported.len() == 1 {
701                let vm_arc = dep_exported.values().next().unwrap().clone();
702                shells.insert(alias.clone(), vm_arc);
703            }
704        }
705
706        // Local helper: on any failure below, run this effect's own cleanup
707        // body (best-effort) and release dep guards under that cleanup
708        // span, then propagate. Warnings from the partial-teardown are
709        // discarded - the test is failing anyway; surfacing extra cleanup
710        // noise on top would obscure the root failure. Defined after the
711        // `shells` binding because macro hygiene resolves `&shells`
712        // against the binding visible at the macro's definition site.
713        //
714        // The final argument is `self.test_span`, NOT the local
715        // `parent_span` - on the recursive sub-effect path, `parent_span`
716        // is the grandparent effect's open EffectSetup span. Cleanup
717        // spans must always anchor under the test span (85eef51).
718        macro_rules! try_guards {
719            ($e:expr) => {{
720                match $e {
721                    Ok(v) => v,
722                    Err(failure) => {
723                        let guards_taken = std::mem::take(&mut dep_guards);
724                        // Pin the setup span's end_ts to the moment of
725                        // failure, before awaiting cleanup. The `setup_span`
726                        // SpanGuard local would otherwise stay alive through
727                        // `run_effect_cleanup`'s shells-shutdown + cleanup-
728                        // block phase and only drop when this function
729                        // unwinds, leaving end_ts near test-end instead of
730                        // setup-end.
731                        self.rt_ctx.log.close_span(setup_span_id);
732                        self.run_effect_cleanup(
733                            effect.name().name(),
734                            start.alias().map(String::from),
735                            setup_span_id,
736                            &marker,
737                            key,
738                            &scope,
739                            &shells,
740                            cleanup_block.as_ref(),
741                            guards_taken,
742                            self.test_span,
743                        )
744                        .await;
745                        return Err(failure.into());
746                    }
747                }
748            }};
749        }
750
751        // 5c. Inject dependency-exposed variables into the effect scope so
752        //      they're accessible via ${Alias.var_name} in shell blocks.
753        {
754            let mut vars = scope.vars().lock().await;
755            for (alias, var_map) in &dep_vars {
756                for (var_name, value) in var_map {
757                    vars.insert(format!("{alias}.{var_name}"), value.clone());
758                }
759            }
760        }
761
762        // 6. Walk IrEffectItems (lets already evaluated, starts already
763        //    instantiated, cleanup block already extracted above).
764        for item in effect.body() {
765            match item {
766                IrEffectItem::Comment { .. }
767                | IrEffectItem::Expect { .. }
768                | IrEffectItem::Start { .. }
769                | IrEffectItem::Expose { .. }
770                | IrEffectItem::Let { .. }
771                | IrEffectItem::PureMatch { .. }
772                | IrEffectItem::Cleanup { .. } => continue,
773                IrEffectItem::Shell { block, .. } => {
774                    let switch_span = block.name().span();
775                    if let Some(qualifier) = block.qualifier() {
776                        // Qualified: alias.shell { ... }
777                        let alias = qualifier.name();
778                        let shell_name = block.name().name();
779                        let display = format!("{alias}.{shell_name}");
780                        let block_span = self.rt_ctx.log.open_span(
781                            SpanKind::ShellBlock {
782                                shell: display.clone(),
783                            },
784                            Some(setup_span_id),
785                            Some(switch_span),
786                        );
787                        let block_span_id = block_span.id();
788                        let dep =
789                            try_guards!(dep_shells.get(alias).ok_or_else(|| Failure::Runtime {
790                                message: format!("unknown effect alias `{alias}`"),
791                                span: qualifier.span().clone(),
792                                shell: None,
793                                context: FailureContext::pre_vm_with_span(block_span_id),
794                            }));
795                        let vm_arc =
796                            try_guards!(dep.get(shell_name).ok_or_else(|| Failure::Runtime {
797                                message: format!(
798                                    "effect alias `{alias}` does not expose shell `{shell_name}`"
799                                ),
800                                span: block.name().span().clone(),
801                                shell: None,
802                                context: FailureContext::pre_vm_with_span(block_span_id),
803                            }));
804                        let exec_result = {
805                            let mut vm = vm_arc.lock().await;
806                            let vm_name = vm.current_name();
807                            let vm_marker = vm.shell_marker().to_string();
808                            self.rt_ctx.log.emit_shell_switch(
809                                block_span_id,
810                                &vm_name,
811                                &vm_marker,
812                                None,
813                            );
814                            vm.set_block_span(block_span_id);
815                            vm.exec_stmts(block.body()).await
816                            // vm lock drops at end of this block, BEFORE try_guards! awaits any
817                            // release_and_teardown that would re-lock the same vm via
818                            // teardown_effect::shutdown.
819                        };
820                        // Pin the shell-block's end_ts to "body done" before
821                        // `try_guards!` may await `run_effect_cleanup`. The
822                        // local `block_span` guard would otherwise stay on the
823                        // stack through the entire cleanup phase and only
824                        // close when this function unwinds, leaving the viewer
825                        // with a shell-block that appears active alongside
826                        // cleanup operations.
827                        self.rt_ctx.log.close_span(block_span_id);
828                        try_guards!(exec_result);
829                        // block_span drops here as a no-op (already closed).
830                    } else {
831                        // Unqualified: shell name { ... }
832                        let name = block.name().name().to_string();
833                        let block_span = self.rt_ctx.log.open_span(
834                            SpanKind::ShellBlock {
835                                shell: name.clone(),
836                            },
837                            Some(setup_span_id),
838                            Some(switch_span),
839                        );
840                        let block_span_id = block_span.id();
841                        if !shells.contains_key(&name) {
842                            let shell_state = ShellState::new(name.clone());
843                            let ctx = ExecutionContext::new(
844                                scope.clone(),
845                                shell_state,
846                                self.rt_ctx.shell.default_timeout.clone(),
847                                self.rt_ctx.env.clone(),
848                                block_span_id,
849                            );
850                            let shell_key = ShellInstanceKey::Effect {
851                                effect: key.clone(),
852                                shell_name: name.clone(),
853                            };
854                            let vm = try_guards!(
855                                Vm::new(
856                                    name.clone(),
857                                    shell_key.marker(),
858                                    ctx,
859                                    &self.rt_ctx,
860                                    block.span().clone(),
861                                )
862                                .await
863                            );
864                            shells.insert(name.clone(), Arc::new(TokioMutex::new(vm)));
865                        }
866                        let exec_result = {
867                            let vm_arc = shells.get(&name).expect("shell just inserted above");
868                            let mut vm = vm_arc.lock().await;
869                            let display_name = vm.current_name();
870                            let display_marker = vm.shell_marker().to_string();
871                            self.rt_ctx.log.emit_shell_switch(
872                                block_span_id,
873                                &display_name,
874                                &display_marker,
875                                None,
876                            );
877                            vm.set_block_span(block_span_id);
878                            vm.exec_stmts(block.body()).await
879                            // vm lock drops at end of this block, BEFORE try_guards! awaits any
880                            // release_and_teardown that would re-lock the same vm.
881                        };
882                        // Pin the shell-block's end_ts to "body done" before
883                        // `try_guards!` may await `run_effect_cleanup`. See the
884                        // matching comment in the qualified arm above.
885                        self.rt_ctx.log.close_span(block_span_id);
886                        try_guards!(exec_result);
887                        // block_span drops here as a no-op (already closed).
888                    }
889                }
890            }
891        }
892
893        // 7. Resolve expose declarations - mark which shells/vars are exposed
894        let mut exposed: HashSet<String> = HashSet::new();
895        let mut exposed_vars: HashMap<String, String> = HashMap::new();
896
897        let effect_vars = scope.vars().lock().await;
898        for expose in effect.exposes() {
899            let exposed_name = expose.exposed_name().to_string();
900            match expose.kind() {
901                relux_ir::IrExposeKind::Shell => {
902                    if let Some(qualifier) = expose.qualifier() {
903                        let dep = try_guards!(dep_shells.get(qualifier).ok_or_else(|| {
904                            Failure::Runtime {
905                                message: format!(
906                                    "effect `{}` expose references unknown alias `{}`",
907                                    effect.name().name(),
908                                    qualifier,
909                                ),
910                                span: expose
911                                    .qualifier_span()
912                                    .expect("qualified expose has a qualifier span")
913                                    .clone(),
914                                shell: None,
915                                context: FailureContext::pre_vm_with_span(setup_span_id),
916                            }
917                        }));
918                        let vm_arc = try_guards!(dep.get(expose.target()).ok_or_else(|| {
919                            Failure::Runtime {
920                                message: format!(
921                                    "effect `{}` expose references shell `{}` not exposed by `{}`",
922                                    effect.name().name(),
923                                    expose.target(),
924                                    qualifier,
925                                ),
926                                span: expose.target_span().clone(),
927                                shell: None,
928                                context: FailureContext::pre_vm_with_span(setup_span_id),
929                            }
930                        }));
931                        shells.insert(exposed_name.clone(), vm_arc.clone());
932                        exposed.insert(exposed_name.clone());
933                    } else {
934                        if !shells.contains_key(expose.target()) {
935                            try_guards!(Err::<(), _>(Failure::Runtime {
936                                message: format!(
937                                    "effect `{}` expose references unknown shell `{}`",
938                                    effect.name().name(),
939                                    expose.target(),
940                                ),
941                                span: expose.target_span().clone(),
942                                shell: None,
943                                context: FailureContext::pre_vm_with_span(setup_span_id),
944                            }));
945                        }
946                        if exposed_name != expose.target() {
947                            let vm_arc = shells.get(expose.target()).unwrap().clone();
948                            shells.insert(exposed_name.clone(), vm_arc);
949                        }
950                        exposed.insert(exposed_name.clone());
951                    }
952                    self.rt_ctx.log.emit_effect_expose_shell(
953                        setup_span_id,
954                        &exposed_name,
955                        expose.target(),
956                        expose.qualifier(),
957                        None,
958                    );
959                }
960                relux_ir::IrExposeKind::Var => {
961                    let value = if let Some(qualifier) = expose.qualifier() {
962                        // Re-expose a variable from a dependency
963                        let qualifier_vars =
964                            try_guards!(dep_vars.get(qualifier).ok_or_else(|| {
965                                Failure::Runtime {
966                                    message: format!(
967                                        "effect `{}` expose references unknown alias `{}`",
968                                        effect.name().name(),
969                                        qualifier,
970                                    ),
971                                    span: expose
972                                        .qualifier_span()
973                                        .expect("qualified expose has a qualifier span")
974                                        .clone(),
975                                    shell: None,
976                                    context: FailureContext::pre_vm_with_span(setup_span_id),
977                                }
978                            }));
979                        try_guards!(qualifier_vars.get(expose.target()).ok_or_else(|| {
980                            Failure::Runtime {
981                                message: format!(
982                                    "effect `{}` expose references var `{}` not exposed by `{}`",
983                                    effect.name().name(),
984                                    expose.target(),
985                                    qualifier,
986                                ),
987                                span: expose.target_span().clone(),
988                                shell: None,
989                                context: FailureContext::pre_vm_with_span(setup_span_id),
990                            }
991                        }))
992                        .clone()
993                    } else {
994                        // Expose a local let-bound variable
995                        effect_vars.get(expose.target()).unwrap_or("").to_string()
996                    };
997                    exposed_vars.insert(exposed_name.clone(), value.clone());
998                    self.rt_ctx.log.emit_effect_expose_var(
999                        setup_span_id,
1000                        &exposed_name,
1001                        expose.target(),
1002                        expose.qualifier(),
1003                        &value,
1004                        None,
1005                    );
1006                }
1007            }
1008        }
1009        drop(effect_vars);
1010
1011        // 8. Terminate non-exposed local shells (deduplicate by Arc pointer).
1012        //    Collect pointers of exposed VMs first - a non-exposed key may alias
1013        //    the same Arc as an exposed key (e.g. backwards-compat single-shell alias),
1014        //    so we must not shut those down.
1015        let exposed_ptrs: HashSet<usize> = shells
1016            .iter()
1017            .filter(|(k, _)| exposed.contains(k.as_str()))
1018            .map(|(_, v)| Arc::as_ptr(v) as usize)
1019            .collect();
1020        let non_exposed_keys: Vec<String> = shells
1021            .keys()
1022            .filter(|k| !exposed.contains(k.as_str()))
1023            .cloned()
1024            .collect();
1025        for key in non_exposed_keys {
1026            if let Some(vm_arc) = shells.remove(&key) {
1027                let ptr = Arc::as_ptr(&vm_arc) as usize;
1028                if !exposed_ptrs.contains(&ptr) {
1029                    vm_arc.lock().await.shutdown().await;
1030                }
1031            }
1032        }
1033
1034        // setup_span drops here, closing the span.
1035
1036        Ok(EffectHandle {
1037            scope,
1038            shells,
1039            exposed,
1040            exposed_vars,
1041            dep_guards,
1042            cleanup: cleanup_block,
1043            setup_span: setup_span_id,
1044            key: key.clone(),
1045            marker,
1046            alias: start.alias().map(String::from),
1047        })
1048    }
1049
1050    /// Surface form of an evaluated overlay, used wherever a structured
1051    /// `EffectSetup` span needs the overlay as `(key, value)` pairs.
1052    /// Same conversion used by bootstrap and reuse paths so dedup'd
1053    /// acquires render identically to bootstraps.
1054    fn evaluated_overlay_pairs(overlay: &Env) -> Vec<(String, String)> {
1055        overlay
1056            .iter()
1057            .map(|(k, v)| (k.to_string(), v.to_string()))
1058            .collect()
1059    }
1060
1061    async fn eval_overlay(
1062        &self,
1063        start: &IrEffectStart,
1064        caller_vars: &VarScope,
1065        caller_env: &Arc<LayeredEnv>,
1066        caller_span: SpanId,
1067        caller_captures: &std::collections::HashMap<String, String>,
1068    ) -> Result<Env, ExecError> {
1069        let mut overlay = Env::new();
1070        let mut sink =
1071            crate::observe::structured::log_sink::LogSink::new(&self.rt_ctx.log, caller_span);
1072        for entry in start.overlay() {
1073            let value = match relux_ir::evaluator::eval_pure_expr(
1074                entry.value(),
1075                caller_vars,
1076                caller_captures,
1077                caller_env,
1078                &self.rt_ctx.tables.pure_fns,
1079                &mut sink,
1080            ) {
1081                Ok(v) => v,
1082                Err(err) => {
1083                    let vars_in_scope = caller_vars.snapshot();
1084                    return Err(pure_eval_failure(
1085                        err,
1086                        caller_span,
1087                        MatchContext::EffectPreamble {
1088                            name: start.effect().name.to_string(),
1089                        },
1090                        vars_in_scope,
1091                        &sink,
1092                        &self.rt_ctx.log,
1093                    ));
1094                }
1095            };
1096            overlay.insert(entry.key().name().to_string(), value);
1097        }
1098        Ok(overlay)
1099    }
1100
1101    /// Glue: release one guard, then either run its cleanup body (when
1102    /// this caller was the last holder) or open a zero-duration
1103    /// deferred-cleanup span (otherwise).
1104    fn release_and_teardown<'a>(
1105        &'a self,
1106        guard: EffectGuard,
1107        parent_span: SpanId,
1108    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Vec<Warning>> + Send + 'a>> {
1109        Box::pin(async move {
1110            match guard.release().await {
1111                ReleaseOutcome::LastHolder { handle } => {
1112                    self.teardown_effect(*handle, parent_span).await
1113                }
1114                ReleaseOutcome::Deferred {
1115                    effect,
1116                    alias,
1117                    setup_span,
1118                    marker,
1119                } => {
1120                    let deferred = self.rt_ctx.log.open_span(
1121                        SpanKind::EffectCleanup {
1122                            effect,
1123                            alias,
1124                            setup_span,
1125                            marker,
1126                            is_deferred: true,
1127                        },
1128                        Some(parent_span),
1129                        None,
1130                    );
1131                    deferred.close();
1132                    Vec::new()
1133                }
1134                ReleaseOutcome::Drift => Vec::new(),
1135            }
1136        })
1137    }
1138
1139    /// Run cleanup for one effect we now exclusively own.
1140    ///
1141    /// Thin wrapper around `run_effect_cleanup`: destructures the handle
1142    /// and forwards the components. The partial-setup-failure path in
1143    /// `bootstrap_effect` calls `run_effect_cleanup` directly with the
1144    /// same fields collected from local state.
1145    async fn teardown_effect(&self, handle: EffectHandle, parent_span: SpanId) -> Vec<Warning> {
1146        let effect_name = handle.scope.name().to_string();
1147        self.run_effect_cleanup(
1148            &effect_name,
1149            handle.alias,
1150            handle.setup_span,
1151            &handle.marker,
1152            &handle.key,
1153            &handle.scope,
1154            &handle.shells,
1155            handle.cleanup.as_ref(),
1156            handle.dep_guards,
1157            parent_span,
1158        )
1159        .await
1160    }
1161
1162    /// Run an effect's cleanup sequence.
1163    ///
1164    /// Sequence:
1165    ///   1. Open `EffectCleanup` span (parent = `parent_span`).
1166    ///   2. Shut down all owned VMs (deduplicated by Arc pointer).
1167    ///   3. If a cleanup block exists, run it inside a `CleanupBlock`
1168    ///      span; collect `Warning::CleanupFailed` on error.
1169    ///   4. Concurrently `release_and_teardown` every dep guard the
1170    ///      handle was holding (parented under the cleanup span).
1171    ///   5. Close cleanup span (after step 4 so deferred-cleanup spans
1172    ///      emitted by dep releases are well-ordered children).
1173    ///
1174    /// Used by both the success path (`teardown_effect`, via an
1175    /// `EffectHandle`) and the partial-setup-failure path
1176    /// (`bootstrap_effect`'s `try_guards!` macro, with the in-flight
1177    /// local state). The two call sites pass the same kind of
1178    /// information; collecting it once here keeps the cleanup
1179    /// semantics identical regardless of how the effect's lifecycle
1180    /// ended.
1181    #[allow(clippy::too_many_arguments)]
1182    async fn run_effect_cleanup(
1183        &self,
1184        effect_name: &str,
1185        alias: Option<String>,
1186        setup_span: SpanId,
1187        marker: &str,
1188        key: &EffectInstanceKey,
1189        scope: &Scope,
1190        shells: &HashMap<String, Arc<TokioMutex<Vm>>>,
1191        cleanup_block: Option<&IrCleanupBlock>,
1192        dep_guards: Vec<EffectGuard>,
1193        parent_span: SpanId,
1194    ) -> Vec<Warning> {
1195        let mut warnings = Vec::new();
1196
1197        let cleanup_span = self.rt_ctx.log.open_span(
1198            SpanKind::EffectCleanup {
1199                effect: effect_name.to_string(),
1200                alias,
1201                setup_span,
1202                marker: marker.to_string(),
1203                is_deferred: false,
1204            },
1205            Some(parent_span),
1206            None,
1207        );
1208        let cleanup_span_id = cleanup_span.id();
1209
1210        // Shut down all VMs (exposed and non-exposed, deduplicated).
1211        let mut seen = HashSet::new();
1212        for vm_arc in shells.values() {
1213            let ptr = Arc::as_ptr(vm_arc) as usize;
1214            if seen.insert(ptr) {
1215                vm_arc.lock().await.shutdown().await;
1216            }
1217        }
1218
1219        // Run cleanup block in fresh shell (best-effort).
1220        if let Some(cleanup_block) = cleanup_block {
1221            let block_loc = cleanup_block.span();
1222            let block_span = self.rt_ctx.log.open_span(
1223                SpanKind::CleanupBlock,
1224                Some(cleanup_span_id),
1225                Some(block_loc),
1226            );
1227            let block_span_id = block_span.id();
1228            let cleanup_shell_key = ShellInstanceKey::Effect {
1229                effect: key.clone(),
1230                shell_name: "__cleanup".into(),
1231            };
1232            let cleanup_marker = cleanup_shell_key.marker();
1233            let cleanup_result = self
1234                .run_cleanup_block(cleanup_block, scope, &cleanup_marker, block_span_id)
1235                .await;
1236            if let Err(failure) = cleanup_result {
1237                self.rt_ctx.log.emit_warning(
1238                    block_span_id,
1239                    "__cleanup",
1240                    &cleanup_marker,
1241                    &format!("effect {effect_name} cleanup failed"),
1242                    None,
1243                );
1244                warnings.push(Warning::CleanupFailed {
1245                    source: CleanupSource::Effect {
1246                        name: effect_name.to_string(),
1247                    },
1248                    failure,
1249                });
1250            }
1251            // block_span drops here, closing the span.
1252        }
1253
1254        // Concurrently release dep guards under our own cleanup span:
1255        // dep cleanups (including deferred-release spans for the
1256        // diamond's non-last holder) parent under this cleanup, not
1257        // under our caller. The diamond serialization still happens
1258        // inside `release` (atomic decrement under slot mutex);
1259        // join_all lets independent branches make progress.
1260        let dep_futures = dep_guards
1261            .into_iter()
1262            .map(|g| self.release_and_teardown(g, cleanup_span_id));
1263        let dep_warnings: Vec<Warning> =
1264            join_all(dep_futures).await.into_iter().flatten().collect();
1265        warnings.extend(dep_warnings);
1266
1267        // Close cleanup_span AFTER the recursion so deferred-cleanup
1268        // spans emitted by dep releases (and nested final-cleanup spans
1269        // from dep release-to-zero) are well-ordered children.
1270        cleanup_span.close();
1271        self.rt_ctx.log.push_effect_teardown();
1272
1273        warnings
1274    }
1275
1276    async fn run_cleanup_block(
1277        &self,
1278        cleanup_block: &IrCleanupBlock,
1279        scope: &Scope,
1280        cleanup_marker: &str,
1281        block_span: SpanId,
1282    ) -> Result<(), ExecError> {
1283        let shell_state = ShellState::new("__cleanup".to_string());
1284        let ctx = ExecutionContext::new(
1285            scope.clone(),
1286            shell_state,
1287            self.rt_ctx.shell.default_timeout.clone(),
1288            self.rt_ctx.env.clone(),
1289            block_span,
1290        );
1291        // Cleanup uses its own uncancellable token
1292        let mut cleanup_rt_ctx = self.rt_ctx.clone();
1293        cleanup_rt_ctx.cancel = CancelToken::new();
1294        let mut vm = Vm::new(
1295            "__cleanup".to_string(),
1296            cleanup_marker.to_string(),
1297            ctx,
1298            &cleanup_rt_ctx,
1299            cleanup_block.span().clone(),
1300        )
1301        .await?;
1302        vm.exec_stmts(cleanup_block.body()).await?;
1303        vm.shutdown().await;
1304        Ok(())
1305    }
1306}