Skip to main content

cove_runtime/
error.rs

1//! Errors raised while executing Cove code.
2//!
3//! A `RuntimeError` is a broken invariant, an ungranted capability, or a limit
4//! the host imposed. Ordinary expected failure uses `Result` inside the
5//! language instead.
6//!
7//! Which of the three it is travels with the error, so that a run that ends
8//! with one can say so in its trace without reading its message back.
9
10use cove_diag::{Diagnostic, Span};
11
12use crate::trace::RunOutcome;
13
14/// How many call-site spans [`RuntimeError::with_chain`] keeps, innermost
15/// first.
16///
17/// A bound rather than the whole call stack, because the chain is built for
18/// every error whether the recursion behind it was three frames deep or
19/// the tree-walking interpreter's 256-frame limit — and a diagnostic naming 256
20/// callers would be unreadable long before it is untruthful. Eight is enough
21/// to show a handful of library layers above the fault and to say, honestly,
22/// that there were more: it is not derived from anything else that is
23/// eight, it is simply small enough that a chain this long is already a
24/// wall of `-->` blocks rather than a hint.
25pub const MAX_CALL_CHAIN: usize = 8;
26
27/// The call-site spans a [`RuntimeError`] carries, and how many more there
28/// were than [`MAX_CALL_CHAIN`] keeps.
29///
30/// Its own type, boxed inside [`RuntimeError`], because most errors never
31/// have one: the entry's own failures — most of the eleven end-to-end cases
32/// that raise one, going into issue #258 — carry an empty chain and would
33/// otherwise pay for `Vec`'s three words and this `usize` inline on every
34/// `RuntimeError` there is. `Option<Box<Chain>>` pays a pointer's width
35/// instead, and nothing at all for the common empty case.
36#[derive(Clone, Debug, Default)]
37struct Chain {
38    /// Innermost first — not including this error's own
39    /// [`RuntimeError::span`], which is where it happened rather than who
40    /// called it.
41    sites: Vec<Span>,
42    /// How many call-site spans past [`MAX_CALL_CHAIN`] were dropped to keep
43    /// [`Chain::sites`] bounded — the frames further from the fault, since
44    /// the innermost ones are the ones kept.
45    omitted: usize,
46}
47
48#[derive(Clone, Debug)]
49pub struct RuntimeError {
50    pub message: String,
51    pub span: Option<Span>,
52    /// `Box<str>` rather than `String`, here and for `help` and
53    /// `denied_capability`, because none of the three is ever grown after it
54    /// is set and a `String`'s capacity word is eight bytes this type pays
55    /// on every `Result` that can carry it — of which there are some three
56    /// hundred and sixty signatures. The three together are twenty-four
57    /// bytes, which is the difference between tripping
58    /// `clippy::result_large_err` and clearing it with room.
59    pub rule: Option<Box<str>>,
60    pub help: Option<Box<str>>,
61    /// `None` until [`RuntimeError::with_chain`] attaches one; see [`Chain`]
62    /// for why this is boxed rather than the two fields it holds.
63    chain: Option<Box<Chain>>,
64    /// Which of the three this error is, for the terminal trace event of a
65    /// run that ends with it.
66    ///
67    /// The default is [`RunOutcome::Invariant`], because that is what most of
68    /// them are and because it is the honest answer for an error raised by
69    /// code that knows nothing about limits or boundaries. The two parties
70    /// that do know say so: [`crate::budget::Budget`] names the limit it
71    /// stopped the run for, and [`crate::host::HostRegistry`] names the Host
72    /// API boundary when it is the boundary that refused. It is never
73    /// [`RunOutcome::Success`] or [`RunOutcome::Error`], which are what a run
74    /// that did not fail reports.
75    pub outcome: RunOutcome,
76    /// The capability the Host API boundary refused this call for, when a
77    /// capability was the reason.
78    ///
79    /// [`RunOutcome::HostBoundary`] is set for everything the boundary
80    /// rejects — an unknown module, an operation that does not exist, an
81    /// argument or result the schema does not admit, an exhausted budget —
82    /// so it cannot answer whether this particular run was simply not
83    /// granted enough. This field can: only the grant check in
84    /// [`crate::host::HostRegistry`] sets it, and only with the capability it
85    /// refused.
86    pub denied_capability: Option<Box<str>>,
87}
88
89impl RuntimeError {
90    pub fn new(message: impl Into<String>) -> Self {
91        RuntimeError {
92            message: message.into(),
93            span: None,
94            rule: None,
95            help: None,
96            chain: None,
97            outcome: RunOutcome::Invariant,
98            denied_capability: None,
99        }
100    }
101
102    pub fn at(mut self, span: Span) -> Self {
103        self.span.get_or_insert(span);
104        self
105    }
106
107    /// The call-site spans of the calls that were live when this was raised,
108    /// innermost first, bounded to [`MAX_CALL_CHAIN`] entries by
109    /// [`RuntimeError::with_chain`].
110    pub fn chain(&self) -> &[Span] {
111        self.chain.as_deref().map_or(&[], |chain| &chain.sites)
112    }
113
114    /// How many call-site spans past [`MAX_CALL_CHAIN`] were dropped to keep
115    /// [`RuntimeError::chain`] bounded.
116    pub fn chain_omitted(&self) -> usize {
117        self.chain.as_deref().map_or(0, |chain| chain.omitted)
118    }
119
120    /// Attaches `sites` as the call chain, innermost first, keeping the
121    /// innermost [`MAX_CALL_CHAIN`] and recording how many more were dropped.
122    ///
123    /// A no-op once a chain is attached. The VM and the interpreter each have
124    /// exactly one place that calls this — where the error leaves the
125    /// machine, and inside `call_target` for every level of the
126    /// interpreter's own recursion — and the second of those runs once per
127    /// frame the error unwinds through. The guard is what makes that safe:
128    /// the first frame to see the error attaches the whole chain it can
129    /// still see, and every frame further out finds one already there and
130    /// leaves it alone, rather than overwriting it with the shorter chain
131    /// its own, later vantage point would otherwise compute.
132    pub fn with_chain(mut self, sites: impl IntoIterator<Item = Span>) -> Self {
133        if self.chain.is_some() {
134            return self;
135        }
136        let mut sites = sites.into_iter();
137        let sites_kept = (&mut sites).take(MAX_CALL_CHAIN).collect();
138        let omitted = sites.count();
139        self.chain = Some(Box::new(Chain {
140            sites: sites_kept,
141            omitted,
142        }));
143        self
144    }
145
146    pub fn with_rule(mut self, rule: impl Into<Box<str>>) -> Self {
147        self.rule = Some(rule.into());
148        self
149    }
150
151    pub fn with_help(mut self, help: impl Into<Box<str>>) -> Self {
152        self.help = Some(help.into());
153        self
154    }
155
156    /// Classifies this error as `outcome` for the terminal trace event.
157    ///
158    /// A classification set once is kept: the innermost party to a failure is
159    /// the one that knows what it was, and an error travelling outward
160    /// through a host call or a callback must not be relabelled by whatever
161    /// it passes through on the way.
162    pub fn with_outcome(mut self, outcome: RunOutcome) -> Self {
163        self.outcome = outcome;
164        self
165    }
166
167    /// Records `capability` as the one the Host API boundary refused this
168    /// call for.
169    ///
170    /// Call this only from the grant check itself: it is what lets a caller
171    /// tell "this run was simply not granted enough" apart from the rest of
172    /// what [`RunOutcome::HostBoundary`] covers.
173    pub fn with_denied_capability(mut self, capability: impl Into<Box<str>>) -> Self {
174        self.denied_capability = Some(capability.into());
175        self
176    }
177
178    pub fn to_diagnostic(&self) -> Diagnostic {
179        let mut diagnostic = Diagnostic::error("cove::runtime", self.message.clone());
180        if let Some(span) = self.span {
181            diagnostic = diagnostic.at(span);
182        }
183        if let Some(rule) = &self.rule {
184            diagnostic = diagnostic.rule(rule.clone());
185        }
186        if let Some(help) = &self.help {
187            diagnostic = diagnostic.help(help.clone());
188        }
189        // Every call-site span becomes a secondary label, innermost first, so
190        // a fault raised inside a library call still shows the source line
191        // that called it and not only the library's own. The outermost one
192        // shown also says when the bound cut the chain short, because that
193        // is the label a reader who wants the rest would look at next.
194        let chain = self.chain();
195        let chain_omitted = self.chain_omitted();
196        let last = chain.len().saturating_sub(1);
197        for (i, span) in chain.iter().enumerate() {
198            let message = if i == last && chain_omitted > 0 {
199                format!(
200                    "called from here ({chain_omitted} more call{} not shown)",
201                    if chain_omitted == 1 { "" } else { "s" }
202                )
203            } else {
204                "called from here".to_string()
205            };
206            diagnostic = diagnostic.label(*span, message);
207        }
208        diagnostic
209    }
210}