fix(kotlin-sdk): address Android JNI review issues#4002
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit c373413) |
a0fe9ba
into
feat/kotlin-sdk-and-example-app
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
PR is a well-scoped defensive fix that wraps JNI callbacks in with_local_frame to prevent local-ref table overflow on Android's daemon-attached signer/persistence threads, plus a real bash argv-splitting fix in build_android.sh and expanded CI trigger paths. Three reviewers converged on the same issue: sign_async_trampoline uses manual push_local_frame/pop_local_frame instead of the RAII closure form used everywhere else in the PR — currently correct but not unwind-safe under the outer catch_unwind. Two minor nits on redundant per-address allocations and workflow trigger paths.
Source: reviewers opus for general, gpt-5.5 for general, opus for rust-quality, gpt-5.5 for rust-quality, opus for ffi-engineer, gpt-5.5 for ffi-engineer; verifier opus.
🟡 1 suggestion(s) | 💬 2 nitpick(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-unified-sdk-jni/src/signer.rs`:
- [SUGGESTION] packages/rs-unified-sdk-jni/src/signer.rs:84-116: Use `with_local_frame` in `sign_async_trampoline` for unwind-safe frame cleanup
Every other trampoline in this PR (and `can_sign_trampoline` right below in the same file at line 145) uses `env.with_local_frame(cap, |env| { ... })`, which guarantees the frame is popped on every exit path — including a Rust unwind, since `with_local_frame` pops before resuming. `sign_async_trampoline` alone hand-brackets `push_local_frame(16)` with three separate `pop_local_frame` calls (lines 93, 111, 115). The outer `catch_unwind` at line 69 will swallow any panic between the push and the final pop, and the pushed JNI local frame stays on the daemon-attached Tokio worker for the lifetime of the thread. Because that thread is reused for every state-transition signature, repeated failures accumulate leaked frames on a live worker.
The reason this isn't already using `with_local_frame` is that `Box::into_raw(pending)` must survive across the JNI dispatch so it can be reclaimed on failure. That's expressible in the closure form by returning `Result<(), Box<PendingSign>>` from the closure — the raw pointer itself is unaffected by the frame pop. See Codex's suggested rewrite below.
| @@ -103,9 +108,11 @@ unsafe extern "C" fn sign_async_trampoline( | |||
| ); | |||
| if dispatched.is_err() { | |||
| let _ = env.exception_clear(); | |||
| let _ = env.pop_local_frame(&JObject::null()); | |||
| // Reclaim the token we just leaked and fail the request. | |||
| return Err(Box::from_raw(token as *mut PendingSign)); | |||
| } | |||
| let _ = env.pop_local_frame(&JObject::null()); | |||
| Ok(()) | |||
There was a problem hiding this comment.
🟡 Suggestion: Use with_local_frame in sign_async_trampoline for unwind-safe frame cleanup
Every other trampoline in this PR (and can_sign_trampoline right below in the same file at line 145) uses env.with_local_frame(cap, |env| { ... }), which guarantees the frame is popped on every exit path — including a Rust unwind, since with_local_frame pops before resuming. sign_async_trampoline alone hand-brackets push_local_frame(16) with three separate pop_local_frame calls (lines 93, 111, 115). The outer catch_unwind at line 69 will swallow any panic between the push and the final pop, and the pushed JNI local frame stays on the daemon-attached Tokio worker for the lifetime of the thread. Because that thread is reused for every state-transition signature, repeated failures accumulate leaked frames on a live worker.
The reason this isn't already using with_local_frame is that Box::into_raw(pending) must survive across the JNI dispatch so it can be reclaimed on failure. That's expressible in the closure form by returning Result<(), Box<PendingSign>> from the closure — the raw pointer itself is unaffected by the frame pop. See Codex's suggested rewrite below.
| let token = Box::into_raw(pending) as jlong; | |
| let dispatched = env.with_local_frame(16, |env| -> Result<(), jni::errors::Error> { | |
| let jpubkey = env.byte_array_from_slice(pubkey)?; | |
| let jdata = env.byte_array_from_slice(payload)?; | |
| env.call_method( | |
| ctx.bridge.as_obj(), | |
| "signAsync", | |
| "([BI[BJ)V", | |
| &[ | |
| (&jpubkey).into(), | |
| (key_type as jint).into(), | |
| (&jdata).into(), | |
| token.into(), | |
| ], | |
| )?; | |
| Ok(()) | |
| }); | |
| if dispatched.is_err() { | |
| let _ = env.exception_clear(); | |
| // Reclaim the token we just leaked and fail the request. | |
| return Err(Box::from_raw(token as *mut PendingSign)); | |
| } |
source: ['claude', 'codex']
| @@ -552,7 +555,8 @@ unsafe extern "C" fn tramp_persist_account_address_pools( | |||
| (&path).into(), | |||
| ], | |||
| )? | |||
| .i()?; | |||
| .i() | |||
| })?; | |||
There was a problem hiding this comment.
💬 Nitpick: user/friend pool identity arrays reallocated per address
Before the inner per-address with_local_frame(32, ...) was added, spec.user_identity_id and spec.friend_identity_id were pool-scoped and could be built once per pool and reused across every address in the pool. With the inner frame at line 528, both 32-byte arrays are rebuilt for every address entry — an extra pair of byte_array_from_slice upcalls and JVM allocations per address. The correctness win (bounded local-ref usage) is worth it, but if pools with many addresses are common the shared identity arrays could be hoisted to a pool-level frame while the per-address frame handles only the entry-specific refs. Not blocking; the current shape is simpler.
source: ['claude']
| - 'packages/rs-*/**' | ||
| - 'packages/rs-unified-sdk-jni/**' | ||
| - 'packages/rs-sdk-ffi/**' | ||
| - 'packages/rs-platform-wallet-ffi/**' |
There was a problem hiding this comment.
💬 Nitpick: packages/rs-*/** already subsumes the three specific rs-* paths
The umbrella glob at line 11 (packages/rs-*/**) already matches packages/rs-unified-sdk-jni/**, packages/rs-sdk-ffi/**, and packages/rs-platform-wallet-ffi/**. GitHub Actions unions the patterns so this is harmless, but the redundancy suggests the three specific entries are load-bearing when they aren't. Drop lines 12–14.
| - 'packages/rs-*/**' | |
| - 'packages/rs-unified-sdk-jni/**' | |
| - 'packages/rs-sdk-ffi/**' | |
| - 'packages/rs-platform-wallet-ffi/**' | |
| - 'packages/rs-*/**' |
source: ['claude']
…handle refs, HASH160 add-key, TLS target gating Review fixes for PR #3999 (QuantumExplorer, thepastaclaw, HashEngineering): - rs-unified-sdk-jni: reject negative amounts/fees/account indexes/key ids at the JNI boundary instead of clamping or bit-casting (core send, asset lock funding, platform-address transfer/withdraw/preflight, token mint/burn/transfer/purchase/set-price, document purchase/set-price); zeroize private-key stack copies in the three slot-derive exports and the contested-vote path; funnel preflight/min-amounts exports through a single out binding so the transient platform-address handle is always destroyed. - kotlin-sdk: atomic getAndSet(0) close() on DataContractRef, ContactRequestRef, EstablishedContactRef (double-free race); ECDSA_HASH160 add-key rows now submit the 20-byte HASH160 payload and key the Keystore entry the way KeystoreSigner looks it up (pure-Kotlin RIPEMD-160 with published test vectors); TEST_PLAN ID-08/ID-11 downgraded to deferred; portable NDK version sort in build_android.sh. - rs-dapi-client/rs-sdk-trusted-context-provider: treat Android like iOS at every target_os gate (native-roots exclusion, DNS pre-check skip, platform user agent) — fixes the channel-create panic on Android. - rs-sdk-ffi/rs-sdk-trusted-context-provider: reqwest TLS backend is now target-gated — Android keeps rustls + webpki roots; all other targets (incl. iOS) restore the default native-tls system trust store. Already fixed at HEAD via #4002 (no change needed): build_android.sh features argv array, JNI local-frame coverage on daemon threads, CI path filters. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Verification
Gradle/Kotlin verification was not run locally because this machine has no Java runtime on PATH.