Skip to content

fix(host): convert undeclared callback throws instead of aborting the process - #452

Merged
decrypto21 merged 1 commit into
mainfrom
feat/host-callback-throw-safety
Aug 19, 2026
Merged

fix(host): convert undeclared callback throws instead of aborting the process#452
decrypto21 merged 1 commit into
mainfrom
feat/host-callback-throw-safety

Conversation

@decrypto21

@decrypto21 decrypto21 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • convert an undeclared foreign callback throw into the error type the callback itself declares, in HostRejection, HostStorageError, HostNavigateRejection and ChainProviderError, instead of letting UniFFI's generic converter panic
  • keep host exceptions away from the FFI entirely for the two callbacks the core declares infallible, on_core_log and auth_state_changed, which have no error type to convert into
  • catch Throwable rather than Exception in every hand-written Kotlin adapter, and bound the reason a product receives at 256 characters
  • decode host-initiated subscription items with a depth limit of 64, so a recursive product-supplied payload ends its own subscription instead of exhausting the thread stack
  • make ChainMessageListener::on_message and on_closed fallible, closing the connection while still delivering on_closed when a listener fails
  • add a CI check that the committed TrUAPIProvider bindings match a fresh generation, and add rust/crates/truapi-provider/ to the filter that decides whether the iOS job runs at all

Three changes, one defect class: untrusted input, or a host that fails in a way it did not declare, must not abort the trusted process. Cargo.toml sets panic = "abort" on the shipping profile, so every one of these was a process kill rather than an error.

Foreign callbacks that throw an undeclared type

A host callback that throws an error type it does not declare is now reported to the product as a rejection. Each of the four error types converts an unexpected foreign error into its own Unknown variant and logs it; without that conversion UniFFI's generic converter panics.

The two infallible callbacks have nowhere to convert into, so the Kotlin adapter stops the exception before it reaches the FFI rather than funnelling it. iOS is not skipped here: Swift declares both members non-throwing (func onCoreLog(marker:detail:), func authStateChanged(state:)), so a Swift host cannot throw from them at all, and only Kotlin needs the guard.

Each hand-written adapter funnels host throws into the type its callback declares, catching Throwable rather than Exception so a Kotlin Error (TODO(), OutOfMemoryError) cannot escape into a success the product never got. The reason a product receives keeps the message a host wrote, and falls back to the error's type name for values whose stored properties would otherwise cross the boundary.

This covers every host callback, not only Chat: storage, chain, navigation, permissions, confirmation, theme and preimage lookups all reach the same converter.

Recursive decode is depth-bounded

Host-initiated subscription items decode with a depth limit of 64. A recursive product-supplied payload, in practice a custom renderer tree, otherwise decodes until the thread stack is gone, and a stack overflow is not a panic: nothing catches it, and panic = "abort" is irrelevant to it.

64 is measured against the real ProductChatCustomMessageRenderItem: 64 accepted, 65 refused. The deepest tree anywhere in this repo is three or four levels. Depth maps to UI nesting rather than breadth, so a flat list of ten thousand siblings costs one level.

A refused item ends its own subscription and is logged with its request id. The peer sees the same stop frame as a clean teardown and the host sees the same completion, so the log line is the only signal that anything was refused.

The provider's listener callbacks are fallible

ChainMessageListener::on_message and on_closed return Result<(), ChainProviderError>. A listener that fails closes the connection and still receives on_closed: the response stream is take-once, so it cannot be pumped again, and leaving the handle open would queue every later send against a receiver that is gone.

Breaking

ChainMessageListener's two methods now throw, and ChainProviderError gains a .listener(reason:) case, which is source-breaking for an exhaustive Swift switch.

The crate's UniFFI surface has never shipped: there is no @parity/ios-provider release, the release allowlist excludes it, the Maven publication is local-only, and nothing in this repo implements the trait. The npm @parity/truapi-provider builds are wasm, where mod ffi is compiled out entirely.

Validation

  • cargo test --workspace --all-features: 934 passed, 15 ignored
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo +nightly fmt --check
  • cargo check --target wasm32-unknown-unknown -p truapi-server
  • ./scripts/codegen.sh with no tracked-file drift; make uniffi reproduces the committed iOS bindings, and make provider-swift-check the provider's

Three tests carry the claims, and each was mutation-proven against the defect it describes: the unexpected-error conversion fails by panicking when its From impl is removed; the depth bound decodes 256 levels happily when reverted to plain decode; and the throwing-listener test loses on_closed when the pump returns instead of breaking.

The binding checks are the point of the new CI step. rebuild.sh overwrites the committed provider bindings in place, so before this change nothing could fail on drift.

Notes

A host still cannot tell a clean on_closed from one that followed its own listener failing. Widening the signature to say which is a larger change than this PR wants, and nothing in tree builds retry or reconnect on that callback yet; it is worth an issue for whoever does.

NativeCustomRendererObserver is the one remaining foreign trait with infallible methods. Its only implementation arrives with the Android chat surface in the PR stacked on this one, and carries its guard there.

The Swift compile is left to CI rather than run locally: the TRUAPIProvider product added in #276 resolves an unpublished @parity/ios-provider release asset, so xcodebuild needs both TRUAPI_USE_LOCAL_BINARY=1 and TRUAPI_PROVIDER_USE_LOCAL_BINARY=1 with both xcframeworks built first, which is what the iOS package (swift compile) job does.

@decrypto21
decrypto21 requested a review from a team August 19, 2026 11:45
Comment thread rust/crates/truapi-server/src/subscription.rs
Comment thread rust/crates/truapi-provider/src/ffi.rs
Comment thread android/truapi-host/src/main/kotlin/io/parity/truapi/TrUAPIHost.kt

@TarikGul TarikGul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice job just a few comments

@decrypto21
decrypto21 added this pull request to the merge queue Aug 19, 2026
Merged via the queue into main with commit e2f6f2e Aug 19, 2026
18 checks passed
@decrypto21
decrypto21 deleted the feat/host-callback-throw-safety branch August 19, 2026 13:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants