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}