Skip to main content

relux_runtime/vm/
mod.rs

1pub mod bifs;
2pub mod buffer;
3pub mod context;
4mod pty;
5
6use std::collections::HashMap;
7use std::time::Instant;
8
9use crate::cancel::CancelToken;
10use regex::Regex;
11use regex::RegexBuilder;
12
13use crate::RuntimeContext;
14use crate::observe::structured::EventSeq;
15use crate::observe::structured::FnCallKind;
16use crate::observe::structured::MatchContext;
17use crate::observe::structured::SpanId;
18use crate::observe::structured::SpanKind;
19use crate::observe::structured::StructuredLogBuilder;
20use crate::report::result::Cancellation;
21use crate::report::result::ExecError;
22use crate::report::result::Failure;
23use crate::report::result::FailureContext;
24
25/// Size of the per-shell buffer tail snapshotted into a failure record.
26/// Tuned for "fits on one console screen, enough to see what arrived
27/// instead of the expected pattern".
28const BUFFER_TAIL_BYTES: usize = 4096;
29use crate::vm::buffer::FailPatternHit;
30use crate::vm::buffer::MultiMatchHit;
31use crate::vm::buffer::PatternSlot;
32use crate::vm::buffer::regex_error_summary;
33use crate::vm::context::Captures;
34use crate::vm::context::ExecutionContext;
35use crate::vm::context::FailPattern;
36use crate::vm::pty::PtyShell;
37use relux_core::diagnostics::IrSpan;
38use relux_ir::IrCallExpr;
39use relux_ir::IrExpr;
40use relux_ir::IrFn;
41use relux_ir::IrInterpolation;
42use relux_ir::IrMultiMatchPattern;
43use relux_ir::IrPureFn;
44use relux_ir::IrShellStmt;
45use relux_ir::IrTimeout;
46use relux_ir::Tables;
47
48/// Per-pattern state while a multimatch block is running.
49#[derive(Debug)]
50struct MultiSlot {
51    slot: PatternSlot,
52    /// Pattern source string (preserved verbatim from the IR for log
53    /// emission and the failure record).
54    source: String,
55    is_regex: bool,
56    /// `Some` once the pattern has matched in the current block.
57    hit: Option<MultiMatchHit>,
58    /// Set once `MultiMatchPatternDone` is emitted, so we never double-emit.
59    done: bool,
60    /// Set when the per-pattern `Matched` buffer event has been pushed -
61    /// referenced by `MultiMatchPatternDone.buffer_seq`.
62    buffer_seq: Option<EventSeq>,
63}
64
65// --- Vm --------------------------------------------------
66
67pub struct Vm {
68    pty: PtyShell,
69    ctx: ExecutionContext,
70    tables: Tables,
71    pub log: StructuredLogBuilder,
72    shell_prompt: String,
73    pub(crate) cancel: CancelToken,
74    flaky_timeout_multiplier: f64,
75    terminated: bool,
76    /// Stable identity for this shell. Same value for the entire VM
77    /// lifetime, including after `reset_for_export`. Threaded into
78    /// every structured/buffer event this VM emits.
79    shell_marker: String,
80}
81
82impl Vm {
83    pub async fn new(
84        shell_name: String,
85        shell_marker: String,
86        ctx: ExecutionContext,
87        rt_ctx: &RuntimeContext,
88        block_span: IrSpan,
89    ) -> Result<Self, ExecError> {
90        let shell_command = rt_ctx.shell.command.to_string();
91        let shell_prompt = rt_ctx.shell.prompt.to_string();
92
93        let log = rt_ctx.log.clone();
94        let pty = PtyShell::spawn(
95            &shell_command,
96            ctx.process_env(),
97            log.clone(),
98            shell_name.clone(),
99            shell_marker.clone(),
100        )
101        .map_err(|e| Failure::Runtime {
102            message: format!("failed to spawn shell: {e}"),
103            span: block_span.clone(),
104            shell: Some(shell_name.clone()),
105            context: FailureContext::pre_vm_with_span(ctx.current_span()),
106        })?;
107
108        let cancel = rt_ctx.cancel.clone();
109        let span = ctx.current_span();
110
111        let mut vm = Self {
112            pty,
113            ctx,
114            tables: rt_ctx.tables.clone(),
115            log: log.clone(),
116            shell_prompt,
117            cancel,
118            flaky_timeout_multiplier: rt_ctx.flaky_timeout_multiplier,
119            terminated: false,
120            shell_marker: shell_marker.clone(),
121        };
122
123        log.emit_shell_spawn(span, &shell_name, &shell_marker, &shell_command, None);
124
125        vm.pty
126            .init_prompt(
127                &vm.shell_prompt,
128                vm.ctx
129                    .timeout()
130                    .adjusted_duration_with_flaky(vm.flaky_timeout_multiplier),
131            )
132            .await
133            .map_err(|_| Failure::Runtime {
134                message: "shell did not produce prompt during init".to_string(),
135                span: block_span.clone(),
136                shell: Some(shell_name),
137                context: FailureContext::pre_vm_with_span(vm.ctx.current_span()),
138            })?;
139
140        let ready_shell = vm.ctx.current_name();
141        vm.log
142            .emit_shell_ready(span, &ready_shell, &shell_marker, None);
143
144        Ok(vm)
145    }
146
147    /// Current display name for logging (resolves effect chain + alias).
148    pub fn current_name(&self) -> String {
149        self.ctx.current_name()
150    }
151
152    /// Stable identity for this shell (same across `reset_for_export`).
153    pub fn shell_marker(&self) -> &str {
154        &self.shell_marker
155    }
156
157    /// Reset the execution context for shell export (effect -> test/parent effect).
158    pub fn reset_for_export(
159        &mut self,
160        new_scope: context::Scope,
161        parent_alias: Option<String>,
162        parent_effect_name: Option<String>,
163        shell_local_name: String,
164    ) {
165        self.ctx.reset_for_export(
166            new_scope,
167            parent_alias,
168            parent_effect_name,
169            shell_local_name,
170        );
171    }
172
173    pub fn shell_prompt(&self) -> &str {
174        &self.shell_prompt
175    }
176
177    /// Re-parent all subsequent VM emissions onto the given block span.
178    /// Called when a shell is reused across shell blocks.
179    pub fn set_block_span(&mut self, span: SpanId) {
180        self.ctx.set_block_span(span);
181    }
182
183    pub async fn exec_stmts(&mut self, stmts: &[IrShellStmt]) -> Result<String, ExecError> {
184        let mut last = String::new();
185        for stmt in stmts {
186            if self.cancel.is_cancelled() {
187                return Err(self.observed_cancel(None).await);
188            }
189            last = self.exec_stmt(stmt).await?;
190        }
191        Ok(last)
192    }
193
194    /// Build an `ExecError::Cancelled` from the current cancel-token state.
195    /// Emits the `cancelled` event on the current span before returning so
196    /// that the event ordering matches "VM observed cancel, then unwound".
197    pub(crate) async fn observed_cancel(&self, span: Option<IrSpan>) -> ExecError {
198        let context = self.capture_failure_context().await;
199        let reason = self
200            .cancel
201            .reason()
202            .expect("production cancels always carry a reason");
203        let shell = self.ctx.current_name();
204        self.log.emit_cancelled(
205            self.current_span(),
206            Some(&shell),
207            Some(&self.shell_marker),
208            &reason,
209        );
210        let _ = span;
211        ExecError::Cancelled(Cancellation { reason, context })
212    }
213
214    fn current_span(&self) -> SpanId {
215        self.ctx.current_span()
216    }
217
218    /// Snapshot the diagnostic context for a `Failure` produced by this VM.
219    /// Captures the active span, the latest event seq, the resolved call
220    /// stack, the failing shell's buffer tail, and user-visible vars.
221    /// Must be called *at* the failure construction site - once the VM is
222    /// dropped the buffer is gone.
223    pub(crate) async fn capture_failure_context(&self) -> FailureContext {
224        let call_stack = self.log.resolve_stack(self.ctx.current_span());
225        self.capture_failure_context_with_stack(call_stack).await
226    }
227
228    /// Like `capture_failure_context`, but with a pre-resolved call stack.
229    /// The pure-fn-call error path resolves the stack from the innermost
230    /// still-open pure-fn span (see `LogSink::deepest_open_span`) so the
231    /// nested pure-fn frames appear; those frames are descendants of the
232    /// enclosing `FnCall` span and would be missed by resolving from
233    /// `current_span`.
234    pub(crate) async fn capture_failure_context_with_stack(
235        &self,
236        call_stack: Vec<crate::observe::structured::failure::StackFrame>,
237    ) -> FailureContext {
238        FailureContext::Vm {
239            span: self.ctx.current_span(),
240            event_seq: self.log.current_seq(),
241            call_stack,
242            buffer_tail: self.pty.output_buf.snapshot_tail(BUFFER_TAIL_BYTES).await,
243            vars_in_scope: self.ctx.snapshot_user_vars().await,
244        }
245    }
246
247    /// Resolve an interpolation and emit its event in one walk. Locks the
248    /// (uncontended) scope vars, hands the shared renderer this shell's live
249    /// resolution chain, emits the Interpolation event when a value-bearing
250    /// part was present, and returns the substituted string.
251    async fn render_interp(&mut self, expr: &IrInterpolation, location: Option<&IrSpan>) -> String {
252        let guard = self.ctx.scope.vars().lock().await;
253        let (scopes, env) = self.ctx.interp_chain(&guard);
254        let captures = self.ctx.current_captures_map();
255        let rendered = relux_ir::evaluator::render_interpolation(expr, &scopes, env, captures);
256        drop(guard);
257        if rendered.emitted {
258            let shell = self.ctx.current_name();
259            self.log.emit_interpolation(
260                self.current_span(),
261                Some(&shell),
262                Some(&self.shell_marker),
263                &rendered.template,
264                &rendered.result,
265                &rendered.bindings,
266                location,
267            );
268        }
269        rendered.result
270    }
271
272    pub async fn exec_stmt(&mut self, stmt: &IrShellStmt) -> Result<String, ExecError> {
273        use relux_ir::IrNode;
274        let span = stmt.span().clone();
275        self.check_fail(span.clone()).await?;
276        match stmt {
277            IrShellStmt::Comment { .. } => Ok(String::new()),
278            IrShellStmt::FailRegex {
279                pattern,
280                span: ir_span,
281            } => {
282                let pat = self.render_interp(pattern, Some(&span)).await;
283                let shell = self.ctx.current_name();
284                self.log.emit_fail_pattern_set(
285                    self.current_span(),
286                    &shell,
287                    &self.shell_marker,
288                    &pat,
289                    true,
290                    Some(&span),
291                );
292                let re = match RegexBuilder::new(&pat).multi_line(true).crlf(true).build() {
293                    Ok(re) => re,
294                    Err(e) => {
295                        let context = self.capture_failure_context().await;
296                        return Err(Failure::Runtime {
297                            message: format!("invalid fail regex: {}", regex_error_summary(&e)),
298                            span: ir_span.clone(),
299                            shell: Some(self.ctx.current_name().to_string()),
300                            context,
301                        }
302                        .into());
303                    }
304                };
305                let fp = Some(FailPattern::Regex(re));
306                self.ctx.set_fail_pattern(fp);
307                self.check_fail(span).await?;
308                Ok(String::new())
309            }
310            IrShellStmt::FailLiteral { pattern, .. } => {
311                let pat = self.render_interp(pattern, Some(&span)).await;
312                let shell = self.ctx.current_name();
313                self.log.emit_fail_pattern_set(
314                    self.current_span(),
315                    &shell,
316                    &self.shell_marker,
317                    &pat,
318                    false,
319                    Some(&span),
320                );
321                let fp = Some(FailPattern::Literal(pat));
322                self.ctx.set_fail_pattern(fp);
323                self.check_fail(span).await?;
324                Ok(String::new())
325            }
326            IrShellStmt::ClearFailPattern { .. } => {
327                let shell = self.ctx.current_name();
328                self.log.emit_fail_pattern_cleared(
329                    self.current_span(),
330                    &shell,
331                    &self.shell_marker,
332                    Some(&span),
333                );
334                self.ctx.set_fail_pattern(None);
335                Ok(String::new())
336            }
337            IrShellStmt::Timeout { timeout, .. } => {
338                let previous = self.ctx.timeout().clone();
339                self.ctx.set_timeout(timeout.clone());
340                let shell = self.ctx.current_name();
341                self.log.emit_timeout_set(
342                    self.current_span(),
343                    &shell,
344                    &self.shell_marker,
345                    self.ctx.timeout(),
346                    &previous,
347                    Some(&span),
348                );
349                Ok(String::new())
350            }
351            IrShellStmt::Let { stmt: let_stmt, .. } => {
352                let value = if let Some(expr) = let_stmt.value() {
353                    self.eval_expr(expr).await?
354                } else {
355                    String::new()
356                };
357                let shell = self.ctx.current_name();
358                self.log.emit_var_let(
359                    self.current_span(),
360                    Some(&shell),
361                    Some(&self.shell_marker),
362                    let_stmt.name().name(),
363                    &value,
364                    Some(&span),
365                );
366                self.ctx
367                    .let_insert(let_stmt.name().name().to_string(), value.clone());
368                Ok(value)
369            }
370            IrShellStmt::Assign { stmt: assign, .. } => {
371                let value = self.eval_expr(assign.value()).await?;
372                let Some(previous) = self.ctx.assign(assign.name().name(), value.clone()).await
373                else {
374                    let context = self.capture_failure_context().await;
375                    return Err(Failure::Runtime {
376                        message: format!(
377                            "assignment to undeclared variable `{}`",
378                            assign.name().name()
379                        ),
380                        span: assign.name().span().clone(),
381                        shell: Some(self.ctx.current_name().to_string()),
382                        context,
383                    }
384                    .into());
385                };
386                let shell = self.ctx.current_name();
387                self.log.emit_var_assign(
388                    self.current_span(),
389                    &shell,
390                    &self.shell_marker,
391                    assign.name().name(),
392                    &value,
393                    &previous,
394                    Some(&span),
395                );
396                Ok(value)
397            }
398            IrShellStmt::Expr { expr, .. } => self.eval_expr(expr).await,
399            IrShellStmt::Send { payload, .. } => {
400                let data = self.render_interp(payload, Some(&span)).await;
401                let shell = self.ctx.current_name();
402                self.log.emit_send(
403                    self.current_span(),
404                    &shell,
405                    &self.shell_marker,
406                    &data,
407                    Some(&span),
408                );
409                self.send_bytes(format!("{data}\n").as_bytes(), span.clone())
410                    .await?;
411                Ok(data)
412            }
413            IrShellStmt::SendRaw { payload, .. } => {
414                let data = self.render_interp(payload, Some(&span)).await;
415                let shell = self.ctx.current_name();
416                self.log.emit_send(
417                    self.current_span(),
418                    &shell,
419                    &self.shell_marker,
420                    &data,
421                    Some(&span),
422                );
423                self.send_bytes(data.as_bytes(), span.clone()).await?;
424                Ok(data)
425            }
426            IrShellStmt::MatchLiteral { pattern, .. } => {
427                let timeout = self.ctx.timeout().clone();
428                let pat = self.render_interp(pattern, Some(&span)).await;
429                let shell = self.ctx.current_name();
430                self.log.emit_match_start(
431                    self.current_span(),
432                    &shell,
433                    &self.shell_marker,
434                    &pat,
435                    false,
436                    &timeout,
437                    Some(&span),
438                );
439                let match_start = Instant::now();
440                let (mat, buffer_seq) = self
441                    .wait_consume_literal(&pat, &timeout, span.clone())
442                    .await?;
443                let shell = self.ctx.current_name();
444                self.log.emit_match_done_record(
445                    self.current_span(),
446                    &shell,
447                    &self.shell_marker,
448                    &mat.value.0,
449                    match_start.elapsed(),
450                    None,
451                    buffer_seq,
452                    Some(&span),
453                );
454                Ok(pat)
455            }
456            IrShellStmt::MatchRegex { pattern, .. } => {
457                let timeout = self.ctx.timeout().clone();
458                let pat = self.render_interp(pattern, Some(&span)).await;
459                let re = match RegexBuilder::new(&pat).multi_line(true).crlf(true).build() {
460                    Ok(re) => re,
461                    Err(e) => {
462                        let context = self.capture_failure_context().await;
463                        return Err(Failure::Runtime {
464                            message: format!("invalid regex: {}", regex_error_summary(&e)),
465                            span: pattern.span().clone(),
466                            shell: Some(self.ctx.current_name().to_string()),
467                            context,
468                        }
469                        .into());
470                    }
471                };
472                let shell = self.ctx.current_name();
473                self.log.emit_match_start(
474                    self.current_span(),
475                    &shell,
476                    &self.shell_marker,
477                    &pat,
478                    true,
479                    &timeout,
480                    Some(&span),
481                );
482                let match_start = Instant::now();
483                let (mat, buffer_seq) = self
484                    .wait_consume_regex(&pat, &re, &timeout, span.clone())
485                    .await?;
486                let full = mat.value.0.get("0").cloned().unwrap_or_default();
487                let captures = mat.value.0.clone();
488                let shell = self.ctx.current_name();
489                self.log.emit_match_done_record(
490                    self.current_span(),
491                    &shell,
492                    &self.shell_marker,
493                    &full,
494                    match_start.elapsed(),
495                    Some(captures.clone()),
496                    buffer_seq,
497                    Some(&span),
498                );
499                self.set_captures_from_map(captures);
500                Ok(full)
501            }
502            IrShellStmt::PureMatch {
503                lhs,
504                pattern,
505                is_regex,
506                ..
507            } => {
508                let value = self.eval_expr(lhs).await?;
509                let pat = self.render_interp(pattern, Some(&span)).await;
510                let outcome = {
511                    let mut sink = crate::observe::structured::log_sink::LogSink::new_in_shell(
512                        &self.log,
513                        self.current_span(),
514                        self.ctx.current_name(),
515                        self.shell_marker.clone(),
516                    );
517                    relux_ir::eval_pure_match(&mut sink, &value, &pat, *is_regex, &span)
518                };
519                match outcome {
520                    Ok(Some(hit)) => {
521                        let matched = hit.matched_text;
522                        if *is_regex {
523                            self.set_captures_from_map(hit.captures);
524                        }
525                        Ok(matched)
526                    }
527                    Ok(None) => {
528                        let context = self.capture_failure_context().await;
529                        let match_context = match self.ctx.current_fn_name() {
530                            Some(name) => MatchContext::Fn {
531                                name: name.to_string(),
532                            },
533                            None => MatchContext::Shell {
534                                name: self.ctx.current_name(),
535                            },
536                        };
537                        Err(Failure::PureMatch {
538                            value,
539                            pattern: pat,
540                            is_regex: *is_regex,
541                            span: span.clone(),
542                            match_context,
543                            context,
544                        }
545                        .into())
546                    }
547                    Err(e) => {
548                        let context = self.capture_failure_context().await;
549                        Err(Failure::Runtime {
550                            message: crate::report::result::invalid_regex_message(&e.reason),
551                            span: span.clone(),
552                            shell: Some(self.ctx.current_name().to_string()),
553                            context,
554                        }
555                        .into())
556                    }
557                }
558            }
559            IrShellStmt::TimedMatchLiteral {
560                timeout, pattern, ..
561            } => {
562                let pat = self.render_interp(pattern, Some(&span)).await;
563                let shell = self.ctx.current_name();
564                self.log.emit_match_start(
565                    self.current_span(),
566                    &shell,
567                    &self.shell_marker,
568                    &pat,
569                    false,
570                    timeout,
571                    Some(&span),
572                );
573                let match_start = Instant::now();
574                let (mat, buffer_seq) = self
575                    .wait_consume_literal(&pat, timeout, span.clone())
576                    .await?;
577                let shell = self.ctx.current_name();
578                self.log.emit_match_done_record(
579                    self.current_span(),
580                    &shell,
581                    &self.shell_marker,
582                    &mat.value.0,
583                    match_start.elapsed(),
584                    None,
585                    buffer_seq,
586                    Some(&span),
587                );
588                Ok(pat)
589            }
590            IrShellStmt::TimedMatchRegex {
591                timeout, pattern, ..
592            } => {
593                let pat = self.render_interp(pattern, Some(&span)).await;
594                let re = match RegexBuilder::new(&pat).multi_line(true).crlf(true).build() {
595                    Ok(re) => re,
596                    Err(e) => {
597                        let context = self.capture_failure_context().await;
598                        return Err(Failure::Runtime {
599                            message: format!("invalid regex: {}", regex_error_summary(&e)),
600                            span: pattern.span().clone(),
601                            shell: Some(self.ctx.current_name().to_string()),
602                            context,
603                        }
604                        .into());
605                    }
606                };
607                let shell = self.ctx.current_name();
608                self.log.emit_match_start(
609                    self.current_span(),
610                    &shell,
611                    &self.shell_marker,
612                    &pat,
613                    true,
614                    timeout,
615                    Some(&span),
616                );
617                let match_start = Instant::now();
618                let (mat, buffer_seq) = self
619                    .wait_consume_regex(&pat, &re, timeout, span.clone())
620                    .await?;
621                let full = mat.value.0.get("0").cloned().unwrap_or_default();
622                let captures = mat.value.0.clone();
623                let shell = self.ctx.current_name();
624                self.log.emit_match_done_record(
625                    self.current_span(),
626                    &shell,
627                    &self.shell_marker,
628                    &full,
629                    match_start.elapsed(),
630                    Some(captures.clone()),
631                    buffer_seq,
632                    Some(&span),
633                );
634                self.set_captures_from_map(captures);
635                Ok(full)
636            }
637            IrShellStmt::MultiMatch {
638                timeout, patterns, ..
639            } => self.exec_multimatch(patterns, timeout.as_ref(), span).await,
640            IrShellStmt::BufferReset { .. } => {
641                // `clear` emits the `Reset` buffer event internally under
642                // the output_buf lock, so no separate emit is needed here.
643                let _consumed = self.pty.output_buf.clear().await;
644                Ok(String::new())
645            }
646        }
647    }
648
649    async fn exec_multimatch(
650        &mut self,
651        patterns: &[IrMultiMatchPattern],
652        timeout: Option<&IrTimeout>,
653        span: IrSpan,
654    ) -> Result<String, ExecError> {
655        use relux_ir::IrNode;
656        let effective = timeout
657            .cloned()
658            .unwrap_or_else(|| self.ctx.timeout().clone());
659
660        // 1. Interpolate patterns + emit per-pattern interpolation events.
661        let mut compiled: Vec<MultiSlot> = Vec::with_capacity(patterns.len());
662        for ir_pat in patterns {
663            let resolved = self.render_interp(ir_pat.pattern(), Some(&span)).await;
664            let slot = if ir_pat.is_regex() {
665                let re = match RegexBuilder::new(&resolved)
666                    .multi_line(true)
667                    .crlf(true)
668                    .build()
669                {
670                    Ok(re) => re,
671                    Err(e) => {
672                        let context = self.capture_failure_context().await;
673                        return Err(Failure::Runtime {
674                            message: format!("invalid regex: {}", regex_error_summary(&e)),
675                            span: ir_pat.pattern().span().clone(),
676                            shell: Some(self.ctx.current_name().to_string()),
677                            context,
678                        }
679                        .into());
680                    }
681                };
682                PatternSlot::regex(resolved.clone(), re)
683            } else {
684                PatternSlot::literal(resolved.clone())
685            };
686            compiled.push(MultiSlot {
687                slot,
688                source: resolved.clone(),
689                is_regex: ir_pat.is_regex(),
690                hit: None,
691                done: false,
692                buffer_seq: None,
693            });
694        }
695
696        // 2. Open the multi-match span.
697        let shell = self.ctx.current_name();
698        let parent_span = self.current_span();
699        let mm_guard = self
700            .log
701            .open_multimatch_span(parent_span, &shell, Some(&span));
702        let mm_span_id = mm_guard.id();
703        self.ctx.push_span(mm_span_id);
704
705        // 3. Emit MultiMatchStart with pattern metadata.
706        let pattern_meta: Vec<crate::observe::structured::MultiMatchPattern> = compiled
707            .iter()
708            .map(|m| crate::observe::structured::MultiMatchPattern {
709                pattern: m.source.clone(),
710                is_regex: m.is_regex,
711            })
712            .collect();
713        self.log.emit_multimatch_start(
714            mm_span_id,
715            &shell,
716            &self.shell_marker,
717            &pattern_meta,
718            &effective,
719            Some(&span),
720        );
721
722        // 4. Snapshot block-entry offset and start timer.
723        let block_entry = self.pty.output_buf.base_offset().await;
724        let block_start = Instant::now();
725
726        // 5. Run the scan loop. Terminal lifecycle events emit while the
727        //    span is still open.
728        let outcome = self
729            .wait_multimatch(
730                &mut compiled,
731                block_entry,
732                block_start,
733                &effective,
734                &span,
735                mm_span_id,
736            )
737            .await;
738
739        // 6. Close the multi-match span.
740        self.ctx.pop_span();
741        drop(mm_guard);
742
743        outcome.map(|()| String::new())
744    }
745
746    async fn wait_multimatch(
747        &self,
748        slots: &mut [MultiSlot],
749        block_entry: usize,
750        block_start: Instant,
751        timeout: &IrTimeout,
752        span: &IrSpan,
753        mm_span_id: SpanId,
754    ) -> Result<(), ExecError> {
755        let dur = timeout.adjusted_duration_with_flaky(self.flaky_timeout_multiplier);
756        let shell = self.ctx.current_name();
757
758        let fut = async {
759            loop {
760                let notified = self.pty.output_buf.notify.notified();
761
762                // 1. Fail-pattern check (peek-only, no drain).
763                let fail_pat = self.ctx.fail_pattern();
764                if let Some(hit) = self.pty.output_buf.check_fail_pattern(fail_pat).await {
765                    return Err(self.make_fail_pattern_error(hit, span.clone()).await);
766                }
767
768                // 2. Build a temporary slice of pattern slots that are still
769                //    unmatched, then scan in one pass.
770                let mut active_idx: Vec<usize> = Vec::with_capacity(slots.len());
771                let mut active_slots: Vec<PatternSlot> = Vec::with_capacity(slots.len());
772                for (i, s) in slots.iter().enumerate() {
773                    if s.hit.is_none() {
774                        active_idx.push(i);
775                        active_slots.push(s.slot.clone());
776                    }
777                }
778                let hits = self
779                    .pty
780                    .output_buf
781                    .multimatch_scan(&mut active_slots, block_entry)
782                    .await;
783
784                // 3. For each newly-matched slot, emit the per-pattern
785                //    `Matched` buffer event then `MultiMatchPatternDone`.
786                let mut max_end: Option<(usize, EventSeq)> = None;
787                for (k, hit_opt) in hits.into_iter().enumerate() {
788                    let Some(hit) = hit_opt else { continue };
789                    let i = active_idx[k];
790                    let buffer_seq = self.pty.output_buf.push_multimatch_matched_event(
791                        hit.before.clone(),
792                        hit.matched_text.clone(),
793                        hit.after.clone(),
794                    );
795                    let end_abs = hit.end_abs;
796                    slots[i].hit = Some(hit);
797                    slots[i].buffer_seq = Some(buffer_seq);
798                    slots[i].done = true;
799                    let elapsed = block_start.elapsed();
800                    self.log.emit_multimatch_pattern_done(
801                        mm_span_id,
802                        &shell,
803                        &self.shell_marker,
804                        i,
805                        elapsed,
806                        buffer_seq,
807                        Some(span),
808                    );
809                    match max_end {
810                        Some((cur, _)) if end_abs <= cur => {}
811                        _ => max_end = Some((end_abs, buffer_seq)),
812                    }
813                }
814                // Recompute across all matched slots so the chosen advance
815                // is the farthest hit overall, not just within this round.
816                for s in slots.iter() {
817                    if let (Some(h), Some(seq)) = (s.hit.as_ref(), s.buffer_seq) {
818                        match max_end {
819                            Some((cur, _)) if h.end_abs <= cur => {}
820                            _ => max_end = Some((h.end_abs, seq)),
821                        }
822                    }
823                }
824
825                // 4. All slots done?
826                if slots.iter().all(|s| s.hit.is_some()) {
827                    let (final_end, advance_seq) =
828                        max_end.expect("all slots matched -> max_end set");
829                    self.pty.output_buf.drain_to(final_end).await;
830                    self.log.emit_multimatch_done(
831                        mm_span_id,
832                        &shell,
833                        &self.shell_marker,
834                        advance_seq,
835                        Some(span),
836                    );
837                    return Ok::<(), ExecError>(());
838                }
839
840                // 5. Wait for the buffer to grow or cancellation.
841                tokio::select! {
842                    _ = notified => {}
843                    _ = self.cancel.cancelled() => {
844                        return Err(self.observed_cancel(Some(span.clone())).await);
845                    }
846                }
847            }
848        };
849
850        match tokio::time::timeout(dur, fut).await {
851            Ok(result) => result,
852            Err(_) => {
853                let unmatched: Vec<usize> = slots
854                    .iter()
855                    .enumerate()
856                    .filter(|(_, s)| s.hit.is_none())
857                    .map(|(i, _)| i)
858                    .collect();
859                let matched: Vec<usize> = slots
860                    .iter()
861                    .enumerate()
862                    .filter(|(_, s)| s.hit.is_some())
863                    .map(|(i, _)| i)
864                    .collect();
865                self.log.emit_multimatch_timeout(
866                    mm_span_id,
867                    &shell,
868                    &self.shell_marker,
869                    &unmatched,
870                    Some(span),
871                );
872                let pattern_meta: Vec<crate::observe::structured::MultiMatchPattern> = slots
873                    .iter()
874                    .map(|m| crate::observe::structured::MultiMatchPattern {
875                        pattern: m.source.clone(),
876                        is_regex: m.is_regex,
877                    })
878                    .collect();
879                let context = self.capture_failure_context().await;
880                Err(Failure::MultiMatch {
881                    shell: self.ctx.current_name().to_string(),
882                    patterns: pattern_meta,
883                    matched,
884                    span: span.clone(),
885                    effective: Box::new(timeout.clone()),
886                    context,
887                }
888                .into())
889            }
890        }
891    }
892
893    fn set_captures_from_map(&mut self, map: HashMap<String, String>) {
894        let mut caps = Captures::new();
895        for (k, v) in map {
896            caps.set(k, v);
897        }
898        self.ctx.set_captures(caps);
899    }
900
901    #[async_recursion::async_recursion]
902    async fn eval_expr(&mut self, expr: &IrExpr) -> Result<String, ExecError> {
903        use relux_ir::IrNode;
904        let span = expr.span().clone();
905        self.check_fail(span.clone()).await?;
906        match expr {
907            IrExpr::String { value, .. } => {
908                let result = self.render_interp(value, Some(&span)).await;
909                let shell = self.ctx.current_name();
910                self.log.emit_string_eval(
911                    self.current_span(),
912                    &shell,
913                    &self.shell_marker,
914                    &result,
915                    Some(&span),
916                );
917                Ok(result)
918            }
919            IrExpr::Var { name, .. } => Ok(self.ctx.lookup(name).await.unwrap_or_default()),
920            IrExpr::QualifiedVar {
921                qualifier, name, ..
922            } => {
923                let qualified = format!("{qualifier}.{name}");
924                Ok(self.ctx.lookup(&qualified).await.unwrap_or_default())
925            }
926            IrExpr::CaptureRef { index, .. } => Ok(self.ctx.capture(*index).unwrap_or_default()),
927            IrExpr::Call { call, .. } => self.eval_call(call, &span).await,
928        }
929    }
930
931    async fn eval_call(&mut self, call: &IrCallExpr, span: &IrSpan) -> Result<String, ExecError> {
932        let fn_id = call.resolved().clone();
933        let fn_name = call.name().name().to_string();
934
935        // Evaluate args first
936        let mut evaluated_args = Vec::with_capacity(call.args().len());
937        for arg in call.args() {
938            evaluated_args.push(self.eval_expr(arg).await?);
939        }
940
941        // Try user-defined function
942        if let Some(result) = self.tables.fns.get(&fn_id) {
943            let ir_fn = match result.as_ref() {
944                Ok(f) => f,
945                Err(e) => {
946                    let context = self.capture_failure_context().await;
947                    return Err(Failure::Runtime {
948                        message: format!("function resolution failed: {e:?}"),
949                        span: span.clone(),
950                        shell: Some(self.ctx.current_name().to_string()),
951                        context,
952                    }
953                    .into());
954                }
955            };
956            match ir_fn {
957                IrFn::UserDefined { params, body, .. } => {
958                    let params = params.clone();
959                    let body = body.clone();
960                    let named_args: Vec<(String, String)> = params
961                        .iter()
962                        .zip(evaluated_args.iter())
963                        .map(|(p, v)| (p.name().to_string(), v.clone()))
964                        .collect();
965                    let parent_span = self.current_span();
966                    let fn_guard = self.log.open_span(
967                        SpanKind::FnCall {
968                            name: fn_name.clone(),
969                            args: named_args.clone(),
970                            result: None,
971                            callee_kind: FnCallKind::User,
972                            is_pure: false,
973                        },
974                        Some(parent_span),
975                        Some(span),
976                    );
977                    self.ctx.push_span(fn_guard.id());
978                    self.ctx
979                        .push_call(fn_name.clone(), named_args.into_iter().collect());
980                    self.log.push_fn_enter(&fn_name);
981                    let mut last = String::new();
982                    for stmt in &body {
983                        match self.exec_stmt(stmt).await {
984                            Ok(v) => last = v,
985                            Err(e) => {
986                                self.ctx.pop_call();
987                                self.ctx.pop_span();
988                                self.log.push_fn_exit();
989                                return Err(e);
990                            }
991                        }
992                    }
993                    self.ctx.pop_call();
994                    self.ctx.pop_span();
995                    self.log.set_fn_call_result(fn_guard.id(), &last);
996                    self.log.push_fn_exit();
997                    return Ok(last);
998                }
999                IrFn::Builtin { name, arity } => {
1000                    // Impure builtin
1001                    if let Some(bif) = bifs::lookup_impure(name, *arity) {
1002                        let positional_args: Vec<(String, String)> = evaluated_args
1003                            .iter()
1004                            .enumerate()
1005                            .map(|(i, v)| (format!("${i}"), v.clone()))
1006                            .collect();
1007                        let parent_span = self.current_span();
1008                        let fn_guard = self.log.open_span(
1009                            SpanKind::FnCall {
1010                                name: fn_name.clone(),
1011                                args: positional_args,
1012                                result: None,
1013                                callee_kind: FnCallKind::Bif,
1014                                is_pure: false,
1015                            },
1016                            Some(parent_span),
1017                            Some(span),
1018                        );
1019                        self.ctx.push_span(fn_guard.id());
1020                        self.log.push_fn_enter(&fn_name);
1021                        let result = bif.call(self, evaluated_args, span).await;
1022                        self.ctx.pop_span();
1023                        if let Ok(ref v) = result {
1024                            self.log.set_fn_call_result(fn_guard.id(), v);
1025                        }
1026                        self.log.push_fn_exit();
1027                        return result;
1028                    }
1029                }
1030            }
1031        }
1032
1033        // Try pure function
1034        if let Some(result) = self.tables.pure_fns.get(&fn_id) {
1035            let ir_fn = match result.as_ref() {
1036                Ok(f) => f,
1037                Err(e) => {
1038                    let context = self.capture_failure_context().await;
1039                    return Err(Failure::Runtime {
1040                        message: format!("pure function resolution failed: {e:?}"),
1041                        span: span.clone(),
1042                        shell: Some(self.ctx.current_name().to_string()),
1043                        context,
1044                    }
1045                    .into());
1046                }
1047            };
1048            let named_args: Vec<(String, String)> = match ir_fn {
1049                IrPureFn::UserDefined { params, .. } => params
1050                    .iter()
1051                    .zip(evaluated_args.iter())
1052                    .map(|(p, v)| (p.name().to_string(), v.clone()))
1053                    .collect(),
1054                IrPureFn::Builtin { .. } => evaluated_args
1055                    .iter()
1056                    .enumerate()
1057                    .map(|(i, v)| (format!("${i}"), v.clone()))
1058                    .collect(),
1059            };
1060            let callee_kind = match ir_fn {
1061                IrPureFn::UserDefined { .. } => FnCallKind::User,
1062                IrPureFn::Builtin { .. } => FnCallKind::Bif,
1063            };
1064            let parent_span = self.current_span();
1065            let fn_guard = self.log.open_span(
1066                SpanKind::FnCall {
1067                    name: fn_name.clone(),
1068                    args: named_args,
1069                    result: None,
1070                    callee_kind,
1071                    is_pure: true,
1072                },
1073                Some(parent_span),
1074                Some(span),
1075            );
1076            self.ctx.push_span(fn_guard.id());
1077            self.log.push_fn_enter(&fn_name);
1078            let mut sink = crate::observe::structured::log_sink::LogSink::new_in_shell(
1079                &self.log,
1080                fn_guard.id(),
1081                self.ctx.current_name(),
1082                self.shell_marker.clone(),
1083            );
1084            let return_value = match relux_ir::evaluator::eval_pure_fn(
1085                ir_fn,
1086                evaluated_args,
1087                &self.ctx.env,
1088                &self.tables.pure_fns,
1089                &mut sink,
1090            ) {
1091                Ok(v) => v,
1092                Err(err) => {
1093                    // Resolve the call chain from the innermost still-open
1094                    // pure-fn span so nested pure-fn frames (descendants of
1095                    // this shell-fn span) render in the failure. When no pure
1096                    // fn is open - a direct match in this fn's own body -
1097                    // resolve from the enclosing `FnCall` span instead, so the
1098                    // frame is not lost. Branching on the span source directly
1099                    // avoids re-deriving `None` from an empty stack.
1100                    let call_stack = match sink.deepest_open_span() {
1101                        Some(leaf) => self.log.resolve_stack(leaf),
1102                        None => self.log.resolve_stack(self.current_span()),
1103                    };
1104                    // Build the match context from the innermost frame before
1105                    // `capture_failure_context_with_stack` consumes `call_stack`.
1106                    let match_context = MatchContext::Fn {
1107                        name: call_stack
1108                            .last()
1109                            .and_then(|f| f.name.clone())
1110                            .unwrap_or_else(|| self.ctx.current_name()),
1111                    };
1112                    // Dropping the sink closes those spans (tighter `end_ts`)
1113                    // before the context snapshots the current seq; the spans
1114                    // stay in the map, so the resolution above still holds.
1115                    drop(sink);
1116                    let context = self.capture_failure_context_with_stack(call_stack).await;
1117                    self.ctx.pop_span();
1118                    self.log.push_fn_exit();
1119                    return Err(Failure::from_pure_eval(err, match_context, context).into());
1120                }
1121            };
1122            self.ctx.pop_span();
1123            self.log.set_fn_call_result(fn_guard.id(), &return_value);
1124            self.log.push_fn_exit();
1125            return Ok(return_value);
1126        }
1127
1128        let context = self.capture_failure_context().await;
1129        Err(Failure::Runtime {
1130            message: format!(
1131                "undefined function `{}` with arity {}",
1132                fn_name,
1133                call.args().len()
1134            ),
1135            span: span.clone(),
1136            shell: Some(self.ctx.current_name().to_string()),
1137            context,
1138        }
1139        .into())
1140    }
1141
1142    // --- Public methods for BIFs -------------------------
1143
1144    pub async fn match_literal(
1145        &mut self,
1146        pattern: &str,
1147        span: &IrSpan,
1148    ) -> Result<String, ExecError> {
1149        let shell = self.ctx.current_name();
1150        let timeout = self.ctx.timeout().clone();
1151        self.log.emit_match_start(
1152            self.current_span(),
1153            &shell,
1154            &self.shell_marker,
1155            pattern,
1156            false,
1157            &timeout,
1158            Some(span),
1159        );
1160        let match_start = Instant::now();
1161        let (mat, buffer_seq) = self
1162            .wait_consume_literal(pattern, &timeout, span.clone())
1163            .await?;
1164        let shell = self.ctx.current_name();
1165        self.log.emit_match_done_record(
1166            self.current_span(),
1167            &shell,
1168            &self.shell_marker,
1169            &mat.value.0,
1170            match_start.elapsed(),
1171            None,
1172            buffer_seq,
1173            Some(span),
1174        );
1175        Ok(pattern.to_string())
1176    }
1177
1178    pub async fn send_line(&mut self, line: &str, span: &IrSpan) -> Result<(), ExecError> {
1179        let shell = self.ctx.current_name();
1180        self.log.emit_send(
1181            self.current_span(),
1182            &shell,
1183            &self.shell_marker,
1184            line,
1185            Some(span),
1186        );
1187        self.send_bytes(format!("{line}\n").as_bytes(), span.clone())
1188            .await?;
1189        Ok(())
1190    }
1191
1192    pub async fn send_raw(&mut self, data: &[u8], span: &IrSpan) -> Result<(), ExecError> {
1193        let display = data
1194            .iter()
1195            .map(|b| format!("\\x{b:02x}"))
1196            .collect::<String>();
1197        let shell = self.ctx.current_name();
1198        self.log.emit_send(
1199            self.current_span(),
1200            &shell,
1201            &self.shell_marker,
1202            &display,
1203            Some(span),
1204        );
1205        self.send_bytes(data, span.clone()).await?;
1206        Ok(())
1207    }
1208
1209    // --- Wait + consume/peek helpers ---------------------
1210
1211    async fn wait_consume_literal(
1212        &self,
1213        pattern: &str,
1214        timeout: &IrTimeout,
1215        span: IrSpan,
1216    ) -> Result<(buffer::Match<buffer::LiteralMatch>, EventSeq), ExecError> {
1217        let dur = timeout.adjusted_duration_with_flaky(self.flaky_timeout_multiplier);
1218        let fut = async {
1219            loop {
1220                let notified = self.pty.output_buf.notify.notified();
1221                let fail_pat = self.ctx.fail_pattern();
1222                match self
1223                    .pty
1224                    .output_buf
1225                    .fail_check_consume_literal(pattern, fail_pat)
1226                    .await
1227                {
1228                    Err(hit) => {
1229                        return Err(self.make_fail_pattern_error(hit, span.clone()).await);
1230                    }
1231                    Ok(Some(result)) => {
1232                        return Ok::<(buffer::Match<buffer::LiteralMatch>, EventSeq), ExecError>(
1233                            result,
1234                        );
1235                    }
1236                    Ok(None) => {}
1237                }
1238                tokio::select! {
1239                    _ = notified => {}
1240                    _ = self.cancel.cancelled() => {
1241                        return Err(self.observed_cancel(Some(span.clone())).await);
1242                    }
1243                }
1244            }
1245        };
1246
1247        match tokio::time::timeout(dur, fut).await {
1248            Ok(result) => result,
1249            Err(_) => {
1250                let shell = self.ctx.current_name();
1251                self.log.emit_timeout(
1252                    self.current_span(),
1253                    &shell,
1254                    &self.shell_marker,
1255                    pattern,
1256                    timeout,
1257                    Some(&span),
1258                );
1259                let context = self.capture_failure_context().await;
1260                Err(Failure::MatchTimeout {
1261                    pattern: pattern.to_string(),
1262                    span,
1263                    shell: self.ctx.current_name().to_string(),
1264                    effective: Box::new(timeout.clone()),
1265                    context,
1266                }
1267                .into())
1268            }
1269        }
1270    }
1271
1272    async fn wait_consume_regex(
1273        &self,
1274        pattern: &str,
1275        re: &Regex,
1276        timeout: &IrTimeout,
1277        span: IrSpan,
1278    ) -> Result<(buffer::Match<buffer::RegexMatch>, EventSeq), ExecError> {
1279        let dur = timeout.adjusted_duration_with_flaky(self.flaky_timeout_multiplier);
1280        let fut = async {
1281            loop {
1282                let notified = self.pty.output_buf.notify.notified();
1283                let fail_pat = self.ctx.fail_pattern();
1284                match self
1285                    .pty
1286                    .output_buf
1287                    .fail_check_consume_regex(re, fail_pat)
1288                    .await
1289                {
1290                    Err(hit) => {
1291                        return Err(self.make_fail_pattern_error(hit, span.clone()).await);
1292                    }
1293                    Ok(Some(result)) => {
1294                        return Ok::<(buffer::Match<buffer::RegexMatch>, EventSeq), ExecError>(
1295                            result,
1296                        );
1297                    }
1298                    Ok(None) => {}
1299                }
1300                tokio::select! {
1301                    _ = notified => {}
1302                    _ = self.cancel.cancelled() => {
1303                        return Err(self.observed_cancel(Some(span.clone())).await);
1304                    }
1305                }
1306            }
1307        };
1308
1309        match tokio::time::timeout(dur, fut).await {
1310            Ok(result) => result,
1311            Err(_) => {
1312                let shell = self.ctx.current_name();
1313                self.log.emit_timeout(
1314                    self.current_span(),
1315                    &shell,
1316                    &self.shell_marker,
1317                    pattern,
1318                    timeout,
1319                    Some(&span),
1320                );
1321                let context = self.capture_failure_context().await;
1322                Err(Failure::MatchTimeout {
1323                    pattern: pattern.to_string(),
1324                    span,
1325                    shell: self.ctx.current_name().to_string(),
1326                    effective: Box::new(timeout.clone()),
1327                    context,
1328                }
1329                .into())
1330            }
1331        }
1332    }
1333
1334    async fn check_fail(&self, span: IrSpan) -> Result<(), ExecError> {
1335        let fail_pat = self.ctx.fail_pattern();
1336        if let Some(hit) = self.pty.output_buf.check_fail_pattern(fail_pat).await {
1337            return Err(self.make_fail_pattern_error(hit, span).await);
1338        }
1339        Ok(())
1340    }
1341
1342    async fn make_fail_pattern_error(&self, hit: FailPatternHit, span: IrSpan) -> ExecError {
1343        let shell = self.ctx.current_name();
1344        self.log.emit_fail_pattern_triggered(
1345            self.current_span(),
1346            &shell,
1347            &self.shell_marker,
1348            &hit.pattern,
1349            hit.is_regex,
1350            &hit.matched_text,
1351            Some(&span),
1352        );
1353        let context = self.capture_failure_context().await;
1354        Failure::FailPatternMatched {
1355            pattern: hit.pattern,
1356            matched_line: hit.matched_text,
1357            span,
1358            shell: self.ctx.current_name().to_string(),
1359            context,
1360        }
1361        .into()
1362    }
1363
1364    async fn send_bytes(&mut self, data: &[u8], span: IrSpan) -> Result<(), ExecError> {
1365        match self.pty.send_bytes(data).await {
1366            Ok(()) => Ok(()),
1367            Err(e) => {
1368                let context = self.capture_failure_context().await;
1369                Err(Failure::ShellExited {
1370                    shell: self.ctx.current_name().to_string(),
1371                    exit_code: e.raw_os_error(),
1372                    span,
1373                    context,
1374                }
1375                .into())
1376            }
1377        }
1378    }
1379
1380    pub async fn shutdown(&mut self) {
1381        // Idempotent: the same VM Arc is reachable from multiple cleanup
1382        // paths (test-level shells map, owning effect's handle, and
1383        // recursively from the dependency effect's handle), and each path
1384        // dedups by Arc pointer only within itself. Without this guard the
1385        // same shell would emit shell-terminate up to N times.
1386        if self.terminated {
1387            return;
1388        }
1389        self.terminated = true;
1390        let shell = self.ctx.current_name();
1391        self.log
1392            .emit_shell_terminate(self.current_span(), &shell, &self.shell_marker, None);
1393        self.pty.shutdown().await;
1394    }
1395}