Skip to content

trait-bridge: sync infallible methods swallow host failures (and worse) across backends #176

Description

@tobocop2

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.jinjaunwrap_or_default() / unwrap_or(())
napi templates/sync_method_{non_,}unit_return.jinjaErr(_) => Default::default() + coercion/parse unwrap_or_default()
magnus templates/sync_method_body.rs.jinjalet _ = e; return Default::default(); + conversion swallows
php templates/sync_method_body.jinjaErr(_) => 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions