Skip to main content

thruput_native_exec/
invoke_error.rs

1use std::cell::Cell;
2use std::error::Error;
3use std::fmt;
4use std::panic;
5use std::panic::UnwindSafe;
6use std::sync::Once;
7use thruput_trace::ffi_enums::InvokeErrorCode;
8
9/// Panic payload classifying how an invocation frame terminated; downcast
10/// at the frame's catch site and mapped to the frame outcome.
11#[derive(Debug, Clone, PartialEq)]
12pub struct InvokeError {
13    /// The code the catch site reports to the executor.
14    pub error_type: InvokeErrorCode,
15}
16
17impl fmt::Display for InvokeError {
18    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
19        write!(f, "InvokeError {}", self.error_type)
20    }
21}
22
23impl Error for InvokeError {}
24
25/// Payload for a deterministic contract failure.
26pub fn deterministic_error() -> InvokeError {
27    InvokeError {
28        error_type: InvokeErrorCode::Deterministic,
29    }
30}
31
32/// Payload for an out-of-gas termination.
33pub fn out_of_gas_error() -> InvokeError {
34    InvokeError {
35        error_type: InvokeErrorCode::OutOfGas,
36    }
37}
38
39/// Payload for an unrecoverable harness/FFI failure (not a contract fault).
40pub fn unrecoverable_error() -> InvokeError {
41    InvokeError {
42        error_type: InvokeErrorCode::Unrecoverable,
43    }
44}
45
46/// Panic payload for the non-error control-flow unwind (`raw_Return`
47/// terminating an invocation frame). Despite traveling as an
48/// [`InvokeError`], the code is [`InvokeErrorCode::None`]: the frame
49/// completed; the panic only unwinds the contract's Rust stack. `raw_Exit`
50/// is not one of these: it reverts the frame, so it unwinds as a
51/// [`deterministic_error`].
52pub fn control_flow_unwind() -> InvokeError {
53    InvokeError {
54        error_type: InvokeErrorCode::None,
55    }
56}
57
58/// Payload for a validation failure.
59pub fn validation_error() -> InvokeError {
60    InvokeError {
61        error_type: InvokeErrorCode::ValidationFailure,
62    }
63}
64
65/// Payload for abandoning the whole transaction.
66pub fn abort_transaction_error() -> InvokeError {
67    InvokeError {
68        error_type: InvokeErrorCode::Abandon,
69    }
70}
71
72thread_local! {
73    /// Nested contract-code regions open on this thread; see
74    /// [`PanicHookGuard`], which is the only thing that raises it. hogd's
75    /// `MockRuntime` captures its owning handler for the same thread reason
76    /// instead of reading the thread-local context slot.
77    static PANIC_HOOK_DEPTH: Cell<usize> = const { Cell::new(0) };
78}
79
80/// Where a contract body panicked.
81///
82/// The SDK holds this same struct inside its `FaultInfo` rather than
83/// mirroring it, so a location is described in one place.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct PanicLocation {
86    /// Source file, as `panic!` recorded it.
87    pub file: String,
88    /// 1-based line within [`file`](Self::file).
89    pub line: u32,
90    /// 1-based column within the line.
91    pub column: u32,
92}
93
94impl std::fmt::Display for PanicLocation {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        write!(f, "{}:{}:{}", self.file, self.line, self.column)
97    }
98}
99
100/// What the harness could see of a contract body's panic.
101///
102/// The message comes from the payload at the catch site and the location from
103/// the panic hook, which is the only place it exists — `catch_unwind` hands
104/// back a payload and nothing else. Either half can be absent: a `panic_any`
105/// of a non-string type has no message, and a panic reaching a catch site
106/// without a matching hook record has no location.
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
108pub struct PanicRecord {
109    /// The panic's message, when its payload carried one.
110    pub message: Option<String>,
111    /// Where the panic happened, when the hook recorded it.
112    pub location: Option<PanicLocation>,
113}
114
115thread_local! {
116    /// What the hook saw of the panic currently unwinding this thread.
117    static HOOKED_PANIC: std::cell::RefCell<Option<PanicRecord>> =
118        const { std::cell::RefCell::new(None) };
119}
120
121/// Take the record the hook stashed for the panic now being caught.
122///
123/// The hook runs during the unwind on the *same thread* that then catches it
124/// — see [`PanicHookGuard`], whose docs spell out why a body and the guard
125/// around it always share a thread. That makes this handoff thread-local by
126/// construction, unlike anything between a caller and its callee, which run
127/// on whichever pool threads resumed them.
128///
129/// `payload` is the authority on the message; a stashed record whose message
130/// disagrees belongs to some other panic (one hooked but never caught here)
131/// and contributes no location rather than a misleading one.
132pub(crate) fn take_panic_record(
133    payload: &(dyn std::any::Any + Send),
134) -> PanicRecord {
135    let message = panic_payload_message(payload);
136    let location = take_hooked_panic()
137        .filter(|hooked| hooked.message == message)
138        .and_then(|hooked| hooked.location);
139    PanicRecord { message, location }
140}
141
142/// Take whatever the hook stashed on this thread, leaving the slot empty.
143fn take_hooked_panic() -> Option<PanicRecord> {
144    HOOKED_PANIC.with(|slot| slot.borrow_mut().take())
145}
146
147/// Activates the classifying panic hook on the calling thread for as long as
148/// it is held, installing it on first use.
149///
150/// Held around contract code and nothing else: by `trampoline` around the mock
151/// body it invokes, and by `MockInvocationFrame::execute` around the lambda
152/// standing in for a frame's body. A panic outside those is a harness bug, and
153/// keeps the default output and an unrecoverable code -- which is why the real
154/// FFI call in `TransactionHandler::root_invoke` holds no guard.
155///
156/// `trampoline` holding it is also what makes the mark land on the right
157/// thread: execution is coroutinized, so a body runs on whichever pool thread
158/// resumed it, but `trampoline` and the body it calls are always the same one.
159///
160/// A count, not a flag, since these nest: a frame's lambda invokes bodies, and
161/// a body can invoke another contract. Decrements on drop, so an unwinding
162/// body still leaves the count balanced.
163pub struct PanicHookGuard;
164
165impl PanicHookGuard {
166    /// Enter a contract body on the calling thread.
167    pub fn enter() -> Self {
168        install_panic_hook();
169        PANIC_HOOK_DEPTH.with(|c| c.set(c.get() + 1));
170        PanicHookGuard
171    }
172}
173
174impl Drop for PanicHookGuard {
175    fn drop(&mut self) {
176        PANIC_HOOK_DEPTH.with(|c| c.set(c.get().saturating_sub(1)));
177    }
178}
179
180static INSTALL_HOOK: Once = Once::new();
181
182fn install_panic_hook() {
183    INSTALL_HOOK.call_once(|| {
184        let prev_hook = panic::take_hook();
185        panic::set_hook(Box::new(move |panic_info| {
186            if !is_panic_hook_active() {
187                prev_hook(panic_info);
188                return;
189            }
190
191            // While an invocation is active the hook only controls printing;
192            // it must not panic or resume_unwind — since rust-lang/rust#158369
193            // (nightly-2026-06-26, Rust 1.98) that aborts the process.
194            // [`InvokeError`] payloads are expected control flow (aborts,
195            // frame unwinds), so they stay silent; anything else is a
196            // contract panic, printed compactly here in place of the default
197            // hook's output. Either way the original payload keeps unwinding
198            // to the enclosing catch site ([`invoke_wrapper`] or the mock
199            // frame executor), which maps non-[`InvokeError`] payloads to a
200            // deterministic error.
201            if panic_info.payload().downcast_ref::<InvokeError>().is_none() {
202                let record = PanicRecord {
203                    message: panic_payload_message(panic_info.payload()),
204                    location: panic_info.location().map(|l| PanicLocation {
205                        file: l.file().to_string(),
206                        line: l.line(),
207                        column: l.column(),
208                    }),
209                };
210                print_panic(&record, panic_info.payload());
211                // Stash what only the hook can see. The catch site takes it
212                // back and cross-checks the message, so a record left behind
213                // by a panic caught somewhere else cannot be misread as this
214                // one's.
215                HOOKED_PANIC.with(|slot| *slot.borrow_mut() = Some(record));
216            }
217        }));
218    });
219}
220
221/// Print a contract panic compactly, in place of the default hook's output.
222///
223/// Reads the already-captured `record` rather than re-inspecting the hook
224/// info, so the text and the value the catch site receives cannot drift.
225/// `payload` is only for the non-string case, which has no message.
226fn print_panic(record: &PanicRecord, payload: &(dyn std::any::Any + Send)) {
227    match &record.message {
228        Some(message) => eprintln!("Panic message: {}", message),
229        None => eprintln!("Panic: {:?}", payload),
230    }
231
232    if let Some(location) = &record.location {
233        eprintln!(
234            "Panic occurred at file '{}' line {}",
235            location.file, location.line
236        );
237    }
238}
239
240/// Whether this thread is inside contract code, and so whether a panic on it
241/// is contract code failing rather than a harness bug.
242pub(crate) fn is_panic_hook_active() -> bool {
243    PANIC_HOOK_DEPTH.with(|c| c.get()) > 0
244}
245
246/// The message from a panic payload, when it carries one.
247///
248/// Covers the two shapes `panic!` produces — a `&'static str` for a literal
249/// message and a `String` for a formatted one. Any other payload (a
250/// `panic_any` of some other type) has no message to report and yields
251/// `None` rather than a placeholder, so a caller can tell "no message" from
252/// a message that happens to read like one.
253fn panic_payload_message(
254    payload: &(dyn std::any::Any + Send),
255) -> Option<String> {
256    if let Some(s) = payload.downcast_ref::<&'static str>() {
257        Some((*s).to_string())
258    } else {
259        payload.downcast_ref::<String>().cloned()
260    }
261}
262
263/// Wrap a contract closure in the catch site that maps its panics to
264/// Thruput invoke error codes.
265///
266/// `Sync` is the load-bearing bound: the wrapper is stored on the contract and
267/// invoked through a shared reference from whichever thread resumed the
268/// coroutine, so `&F` has to be usable there. Cloning an `Rc` capture from two
269/// threads at once would race its refcount, and `Sync` rejects that at the
270/// registration site instead. `Send` goes with it because the captures travel
271/// with the contract's heap allocation.
272pub fn invoke_wrapper<'a, F>(
273    wrapped: F,
274) -> Box<dyn Fn() -> thruput_sys::ForeignInvokeResult + Send + Sync + 'a>
275where
276    F: Fn() + UnwindSafe + Clone + Send + Sync + 'a,
277{
278    Box::new(move || {
279        let w = wrapped.clone();
280        let result = panic::catch_unwind(w);
281
282        match result {
283            Ok(_) => thruput_sys::ForeignInvokeResult {
284                panic: InvokeErrorCode::None.0,
285            },
286            Err(err) => {
287                if let Some(my_error) = err.downcast_ref::<InvokeError>() {
288                    return thruput_sys::ForeignInvokeResult {
289                        panic: my_error.error_type.0,
290                    };
291                }
292                if is_panic_hook_active() {
293                    // An ordinary panic out of contract code; the hook
294                    // already printed it. Deterministic failure of this
295                    // invocation.
296                    //
297                    // Drop the hook's record rather than leave it for some
298                    // later frame to take: this catch site cannot attribute
299                    // a message (it has no frame in hand), but it did catch
300                    // the panic, so every catch site takes what it caught
301                    // and a stale record cannot outlive its panic.
302                    drop(take_hooked_panic());
303                    return thruput_sys::ForeignInvokeResult {
304                        panic: InvokeErrorCode::Deterministic.0,
305                    };
306                }
307                eprintln!("unrecoverable error: {:?}", err);
308                thruput_sys::ForeignInvokeResult {
309                    panic: InvokeErrorCode::Unrecoverable.0,
310                }
311            }
312        }
313    })
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    // A contract panic must classify the same way wherever the body runs:
321    // execution is coroutinized, so a body resumes on whichever pool thread
322    // picked it up rather than the one that dispatched to it. `trampoline`
323    // marks that thread, which is what this covers.
324    #[test]
325    fn mock_body_panic_is_deterministic_on_any_thread() {
326        // Off the activating thread and outside a body, an ordinary panic is
327        // a harness bug -- nothing says a frame is running.
328        let unmarked = std::thread::spawn(|| {
329            invoke_wrapper(|| panic!("contract panic"))().panic
330        })
331        .join()
332        .expect("harness thread panicked");
333        assert_eq!(unmarked, InvokeErrorCode::Unrecoverable.0);
334
335        // Inside a body, on that same foreign thread, it is a deterministic
336        // failure of the invocation -- which is the classification the
337        // executor needs and a thread-local activation depth cannot give.
338        let in_body = std::thread::spawn(|| {
339            let _in_body = PanicHookGuard::enter();
340            invoke_wrapper(|| panic!("contract panic"))().panic
341        })
342        .join()
343        .expect("harness thread panicked");
344        assert_eq!(in_body, InvokeErrorCode::Deterministic.0);
345    }
346
347    #[test]
348    fn panic_hook_guard_balances_on_unwind() {
349        assert!(!is_panic_hook_active());
350        let caught = std::panic::catch_unwind(|| {
351            let _in_body = PanicHookGuard::enter();
352            assert!(is_panic_hook_active());
353            panic!("body panicked");
354        });
355        assert!(caught.is_err());
356        assert!(
357            !is_panic_hook_active(),
358            "guard must decrement when the body unwinds through it"
359        );
360    }
361
362    #[test]
363    fn display_terminates_and_names_the_error_code() {
364        // Regression: Display used to format `self.clone()` with `{}`,
365        // recursing infinitely and crashing on any to_string().
366        let msg = deterministic_error().to_string();
367        assert!(msg.contains("InvokeError"), "got: {msg}");
368        assert!(msg.contains("Deterministic"), "got: {msg}");
369    }
370
371    #[test]
372    fn hook_never_panics_and_leaves_payload_untouched() {
373        // Regression for rust-lang/rust#158369 (first in nightly-2026-06-26):
374        // the hook used to resume_unwind a converted payload from inside the
375        // hook, which now aborts the whole process. Merely completing this
376        // test proves the hook no longer panics while active.
377        let _in_body = PanicHookGuard::enter();
378        let err =
379            panic::catch_unwind(|| panic!("contract panic")).unwrap_err();
380        assert_eq!(
381            err.downcast_ref::<&str>(),
382            Some(&"contract panic"),
383            "the hook must let the original payload unwind unchanged; \
384             conversion is the catch site's job"
385        );
386    }
387
388    #[test]
389    fn invoke_wrapper_converts_panics_inside_a_body() {
390        {
391            // Inside a body: an ordinary contract panic becomes a
392            // deterministic error at the catch site.
393            let _in_body = PanicHookGuard::enter();
394            let result = invoke_wrapper(|| panic!("contract panic"))();
395            assert_eq!(result.panic, InvokeErrorCode::Deterministic.0);
396
397            // InvokeError payloads pass through with their own code.
398            let result =
399                invoke_wrapper(|| std::panic::panic_any(validation_error()))();
400            assert_eq!(result.panic, InvokeErrorCode::ValidationFailure.0);
401        }
402
403        // Outside one, an ordinary panic is a harness bug, not a contract
404        // failure.
405        assert!(!is_panic_hook_active());
406        let result = invoke_wrapper(|| panic!("outside a body"))();
407        assert_eq!(result.panic, InvokeErrorCode::Unrecoverable.0);
408    }
409
410    #[test]
411    fn nested_body_stays_active_after_inner_guard_drops() {
412        assert!(!is_panic_hook_active());
413
414        let outer = PanicHookGuard::enter();
415        {
416            // A mock body invoking another contract re-enters `trampoline`.
417            let _inner = PanicHookGuard::enter();
418        }
419
420        // The inner drop must not end the outer body: a panic here is still
421        // a contract failure. This is why the marker counts rather than
422        // flags.
423        assert!(is_panic_hook_active());
424        let result = invoke_wrapper(|| panic!("mid-nest panic"))();
425        assert_eq!(result.panic, InvokeErrorCode::Deterministic.0);
426
427        drop(outer);
428        assert!(!is_panic_hook_active());
429    }
430}