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}