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}