Problem
Trait-bridge methods that are synchronous and infallible in Rust (no error type — e.g. fn count_tokens(&self, text: &str) -> usize, Plugin::name()) have no channel for a host-language failure. An audit of every backend found three failure classes on that surface:
1. Silent swallow — host failure fabricates a value, no trace
The generated shim turns a raised/thrown host callback into Default::default() (0, "", empty) with no log. The caller cannot distinguish the fabricated default from a real result.
Concrete case: a bridged count_tokens that raises mid-run yields 0 — and a zero chunk size reads as "fits any budget" to a text splitter, silently producing oversized chunks.
| backend |
where |
| pyo3 |
templates/trait_bridge/sync_method_{non_,}unit_return.jinja — unwrap_or_default() / unwrap_or(()) |
| napi |
templates/sync_method_{non_,}unit_return.jinja — Err(_) => Default::default() + coercion/parse unwrap_or_default() |
| magnus |
templates/sync_method_body.rs.jinja — let _ = e; return Default::default(); + conversion swallows |
| php |
templates/sync_method_body.jinja — Err(_) => Default::default() + long()/string()/from_str().unwrap_or_default() |
| wasm |
templates/gen_sync_method_body.jinja — four return Default::default() sites |
| jni |
templates/trait_bridge_method_body.rs.jinja — 3 sites, including discarding the host's own marshaled {"err": …} message |
| rustler |
templates/trait_sync_method_body.rs.jinja — collapses host-error / channel-closed / parse failure into one silent default (host side does log its own raises) |
| extendr |
4 templates/sync_method_*.jinja |
| csharp |
trait_bridge.rs primitive-return adapter — catch (Exception) { return 0; }, exception unbound |
| ffi |
trait_bridge/call_body.rs — null-fn-pointer / null-out_result silent defaults (edge cases) |
2. Crash — host failure takes the process down
| backend |
where |
| go |
generated cgo callbacks have no defer recover() — a Go panic in the host implementation crossing the cgo boundary into Rust is a fatal runtime crash. Additionally, the invalid-handle path return 1 // error: invalid handle fabricates 1 as the actual return value for value-returning slots. |
| dart |
templates/rust_trait_method_block_on.jinja — .join().expect("thread panicked") propagates a panic into the calling core thread, aborting the whole call instead of degrading |
3. ABI mismatch — java FFM upcall stubs don't match the vtable (broken on every call)
The C vtable uses a direct-value convention for sync infallible simple returns: fn(user_data, params...) -> <primitive>, no out-pointers (ffi::trait_bridge::c_return_convention). The java generator emitted every stub with the JSON convention instead — (userData, params, outResult, outError) -> int:
- the stub reads two garbage registers as
outResult/outError addresses → outResult.set(...) is a wild write (SIGSEGV territory)
- Rust reads the stub's
0/1 status code as the return value
So a bridged count_tokens on java was broken on every call, not just on exceptions. (C#'s delegate for the same slot is correct — CountTokensFn(IntPtr, IntPtr) -> ulong — which is the model java should follow.)
Not affected
- swift (non-throwing protocol; infallible at compile time), zig (non-error-union fn required at compile time), c (no exception mechanism)
- async trait methods (their chains surface errors), sync methods with a declared error type (
.map_err branch)
Solution
- Silent swallows: log the host failure (wrapper + method + error) before substituting the default, in every backend above. stderr via
eprintln!/System.err/Console.Error; wasm uses the console.
- go:
defer recover() + stderr log + zero-value return in generated callbacks; invalid-handle logs and returns the zero value instead of 1.
- dart: replace the
expect with match + log + default for infallible methods.
- java: implement the direct-value convention for sync infallible primitive/unit returns (descriptor, MethodType, and handler all matching the vtable slot), with a logged catch returning the default.
No generated-code behavior changes beyond the log lines, the crash guards, and the java ABI correction; no new dependencies.
Problem
Trait-bridge methods that are synchronous and infallible in Rust (no error type — e.g.
fn count_tokens(&self, text: &str) -> usize,Plugin::name()) have no channel for a host-language failure. An audit of every backend found three failure classes on that surface:1. Silent swallow — host failure fabricates a value, no trace
The generated shim turns a raised/thrown host callback into
Default::default()(0, "", empty) with no log. The caller cannot distinguish the fabricated default from a real result.Concrete case: a bridged
count_tokensthat raises mid-run yields0— and a zero chunk size reads as "fits any budget" to a text splitter, silently producing oversized chunks.templates/trait_bridge/sync_method_{non_,}unit_return.jinja—unwrap_or_default()/unwrap_or(())templates/sync_method_{non_,}unit_return.jinja—Err(_) => Default::default()+ coercion/parseunwrap_or_default()templates/sync_method_body.rs.jinja—let _ = e; return Default::default();+ conversion swallowstemplates/sync_method_body.jinja—Err(_) => Default::default()+long()/string()/from_str().unwrap_or_default()templates/gen_sync_method_body.jinja— fourreturn Default::default()sitestemplates/trait_bridge_method_body.rs.jinja— 3 sites, including discarding the host's own marshaled{"err": …}messagetemplates/trait_sync_method_body.rs.jinja— collapses host-error / channel-closed / parse failure into one silent default (host side does log its own raises)templates/sync_method_*.jinjatrait_bridge.rsprimitive-return adapter —catch (Exception) { return 0; }, exception unboundtrait_bridge/call_body.rs— null-fn-pointer / null-out_result silent defaults (edge cases)2. Crash — host failure takes the process down
defer recover()— a Go panic in the host implementation crossing the cgo boundary into Rust is a fatal runtime crash. Additionally, the invalid-handle pathreturn 1 // error: invalid handlefabricates1as the actual return value for value-returning slots.templates/rust_trait_method_block_on.jinja—.join().expect("thread panicked")propagates a panic into the calling core thread, aborting the whole call instead of degrading3. ABI mismatch — java FFM upcall stubs don't match the vtable (broken on every call)
The C vtable uses a direct-value convention for sync infallible simple returns:
fn(user_data, params...) -> <primitive>, no out-pointers (ffi::trait_bridge::c_return_convention). The java generator emitted every stub with the JSON convention instead —(userData, params, outResult, outError) -> int:outResult/outErroraddresses →outResult.set(...)is a wild write (SIGSEGV territory)0/1status code as the return valueSo a bridged
count_tokenson java was broken on every call, not just on exceptions. (C#'s delegate for the same slot is correct —CountTokensFn(IntPtr, IntPtr) -> ulong— which is the model java should follow.)Not affected
.map_errbranch)Solution
eprintln!/System.err/Console.Error; wasm uses the console.defer recover()+ stderr log + zero-value return in generated callbacks; invalid-handle logs and returns the zero value instead of1.expectwith match + log + default for infallible methods.No generated-code behavior changes beyond the log lines, the crash guards, and the java ABI correction; no new dependencies.