ADR 0009: Panic Shielding¶
Status: Accepted Date: 2025-12-03
Context¶
Spikard's bindings cross FFI boundaries into Python, Node.js, Ruby, and PHP. Rust panics that cross these boundaries cause undefined behavior, process crashes, or memory corruption in the host language runtime. Panic shielding is critical to maintain stability and convert fatal errors into structured, serializable error payloads that all languages understand.
The challenge is twofold:
- Catch panics before they escape: All Rust code exposed to FFI must wrap panics using
std::panic::catch_unwind. - Convert to structured payloads: Caught panics must translate to the canonical error format
{ error, code, details }matchingStructuredErrorincrates/spikard-core/src/errors.rsand fixtures intesting_data/validation_errors/schema.json.
Decision¶
Core Principle: shield_panic() Wrapper¶
Define a shield_panic<T, F>() function in crates/spikard-core/src/errors.rs:
pub fn shield_panic<T, F>(f: F) -> Result<T, StructuredError>
where
F: FnOnce() -> T + UnwindSafe,
{
catch_unwind(f).map_err(|_| StructuredError::simple("panic", "Unexpected panic in Rust code"))
}
This function:
- Accepts any closure that produces
Tand isUnwindSafe - Returns
Result<T, StructuredError>with structured error on panic - Maps all panics to code
"panic"and error"Unexpected panic in Rust code"
Binding-Specific Implementation¶
Each binding converts caught panics into language-native errors:
Python (crates/spikard-py)¶
PyO3 handlers wrap handler invocation with panic guards:
use spikard_core::errors::shield_panic;
pub fn call_python_handler(py_fn: &PyAny) -> PyResult<PyObject> {
shield_panic(|| {
// Python handler call
py_fn.call0()
})
.map_err(|err| PyErr::new_err(serde_json::to_string(&err).unwrap_or_default()))
}
The error is serialized to JSON and wrapped in a Python exception, preserving the structured format.
Node.js (crates/spikard-node)¶
napi-rs handlers use napi::Result<T> and convert panics to napi::Error:
use spikard_core::errors::shield_panic;
pub fn call_js_handler(func: &napi::JsFunction) -> napi::Result<napi::JsObject> {
shield_panic(|| {
// JavaScript handler call
func.call_without_args(None)
})
.map_err(|err| napi::Error::from_reason(format!("{:?}", err)))
}
Ruby (crates/spikard-rb)¶
Magnus handlers wrap invocations with panic shielding:
use spikard_core::errors::shield_panic;
impl Handler for RubyHandler {
fn handle(&self, req: Request) -> Pin<Box<dyn Future<Output = HandlerResult> + Send>> {
let cloned = self.clone();
Box::pin(async move {
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
cloned.handle_inner(req)
}));
match result {
Ok(hr) => hr,
Err(_) => HandlerResult::error(500, StructuredError::simple("panic", "Panic in Ruby handler"))
}
})
}
}
PHP (crates/spikard-php)¶
ext-php-rs handlers wrap callable invocations and throw PHP exceptions:
use spikard_core::errors::shield_panic;
use ext_php_rs::exceptions::PhpException;
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
// Call PHP function
ZendCallable::from_zval(&zval).call(&args)
}));
match result {
Ok(zval) => { /* handle return */ },
Err(_) => return Err(PhpException::default(
serde_json::to_string(&StructuredError::simple("panic", "Panic in PHP handler")).unwrap()
).into()),
}
Panic Handling in HTTP Handlers¶
The spikard-http server's handler invocation also wraps panic shielding:
// In crates/spikard-http/src/server/handler.rs
impl<H: Handler + Send + Sync + 'static> Handler for Arc<H> {
fn handle(&self, req: Request) -> Pin<Box<dyn Future<Output = HandlerResult> + Send>> {
Box::pin(async move {
self.handle(req)
.catch_unwind()
.await
.unwrap_or_else(|_| HandlerResult::error(
500,
StructuredError::simple("panic", "Panic in handler")
))
})
}
}
Error Payload Structure¶
All panics translate to this canonical form:
This matches the RFC 9457-compatible ProblemDetails struct and aligns with testing_data/validation_errors/schema.json fixtures, ensuring cross-language consistency.
Consequences¶
Benefits¶
- No undefined behavior: Panics never cross FFI boundaries.
- Consistent error handling: All languages receive structured error JSON.
- Debugging clarity: Error code
"panic"identifies panic vs. domain errors. - Test coverage: Panic scenarios can be validated with
testing_data/panic_handlingfixtures.
Obligations¶
- All FFI entry points must shield: Every Python/Node/Ruby/PHP/WASM entry that calls Rust must wrap panics.
- New bindings must follow pattern: Any new FFI binding (e.g., Java via JNI) must implement panic shielding.
- Panic fixtures: New panic handling scenarios should be added to
testing_data/panic_handlingwith assertions inpackages/python/tests/test_all_fixtures.py. - Documentation: Binding developers must document panic shielding contracts in ADRs and binding-specific guides.
Implementation Checklist¶
- Core
shield_panic()function incrates/spikard-core/src/errors.rs - Python binding panic handling in
crates/spikard-py/src/handler.rs - Node binding panic handling in
crates/spikard-node/src/handler.rs - Ruby binding panic handling in
crates/spikard-rb/src/handler.rs - PHP binding panic handling in
crates/spikard-php/src/php/handler.rs - HTTP server panic handling in
crates/spikard-http/src/server/handler.rs - Panic fixtures in
testing_data/panic_handling - Panic scenarios in
packages/python/tests/test_all_fixtures.py - Language-specific binding documentation
References¶
- Core implementation:
crates/spikard-core/src/errors.rs(StructuredError,shield_panic()) - Python binding:
crates/spikard-py/src/handler.rs - Node binding:
crates/spikard-node/src/handler.rs - Ruby binding:
crates/spikard-rb/src/handler.rs - PHP binding:
crates/spikard-php/src/php/handler.rs - HTTP runtime:
crates/spikard-http/src/server/handler.rs - Error format:
testing_data/validation_errors/schema.json - Related ADRs: ADR 0001 (Architecture), ADR 0005 (Lifecycle Hooks)
- RFC 9457: Problem Details for HTTP APIs (https://tools.ietf.org/html/rfc9457)