Runtime and integration

JNI Exception Boundaries for Android Local AI

Translate native failures into typed application states while preserving build identity, stage, cleanup, and next-task readiness.

Review status: Fact and editorial QA complete; owner review pending · 2026-10-04 · Fact IDs: COVE-LIFECYCLE-01, PRIVACY-01

Direct answer

A JNI boundary should translate failures into a predictable application contract instead of allowing native details to leak into UI state or continuing with invalid resources. Android NDK guidance explains that JNI calls may leave a pending managed exception, that most JNI functions must not be called while one is pending, and that C++ exceptions should not cross the JNI transition into managed code. Native code must check exceptions and either handle/clear them appropriately or return to the managed caller. This is not just style: an unchecked pending exception can make later JNI operations invalid, while a C++ exception crossing the boundary can produce undefined behavior or termination. Define an error contract for each bridge operation. Specify which failures are returned as typed status/result values, which managed exceptions may propagate, who owns buffers and native handles, and what cleanup occurs on every exit. Keep model-load, backend setup, inference, cancellation, output conversion, and close errors distinguishable. Do not convert every failure to an empty string or a generic “AI failed” message. Preserve a redacted native error class and stage for support, but do not send raw prompts, transcripts, file paths, or authentication data through exception text. Check JNI semantics at call sites. After calling a managed method from native code, inspect pending exception state before making further JNI calls; returned values are not valid when the call threw. Release pinned arrays, strings, local/global references, and locks along all paths. Do not reuse a JNIEnv pointer from a different thread; attach/detach and thread ownership must follow the documented JNI rules. If the Java/Kotlin API receives a native error, map it to an application-owned typed state and ensure engine/session cleanup completes before a retry can start. Test boundaries with injected failures rather than relying on production crashes. Cover native initialization failure, a managed exception thrown from a callback, allocation failure, invalid handle, cancellation during a call, output conversion error, and close after partial initialization. For each case assert one cleanup, no stale handle, a classified result, and readiness of the next permitted task. Use CheckJNI or other documented checks in a controlled debug/test environment where available, then verify release diagnostics do not expose private content. This is a design and testing method, not a claim about a specific runtime bug. The current Cove ModelManager has explicit lifecycle states, but those states do not prove every JNI adapter path is exception-safe. Test the actual integration and document what can be observed. Local inference also does not prove that diagnostic logs are private or that the app makes no other network requests; keep error telemetry minimal and separately audited.

A failed native call leaves the next request crashing

A Kotlin screen calls a native inference wrapper. The first request reports an error, but a second request crashes or appears to use stale engine state. The native wrapper may have a pending managed exception or may have skipped cleanup on an early return.

Decision

Create a typed JNI-to-Kotlin error boundary, check pending exceptions, and test resource cleanup and handle state at every exit.

Expected outcome

Each failure becomes a stable application state, native resources are released once, and a permitted next request does not reuse invalid state.

Evidence to collect

The claim becomes reviewable only when the following evidence is attached to the same artifact and test run:

  • Native-to-Kotlin error contract
  • Injected failure fixtures
  • Cleanup and recovery results

A runtime comparison is publishable only when artifact identity, build configuration, selected backend, task fixture, and failures are recorded.

Implementation workflow

  1. Map each JNI operation and owner. For each bridge call, list managed and native inputs, thread, object/handle owner, buffer lifetime, possible exception source, and cleanup owner. Make engine/session ownership explicit outside the UI callback.
  2. Define typed error and exception behavior. Choose which native failures return a status/result and which managed exceptions propagate. After managed calls from native code, check pending exception state before further JNI operations; never throw a C++ exception across the boundary.
  3. Audit resource release on all exits. Review local/global references, pinned arrays/strings, locks, native handles, and partially initialized engine state. Verify early return, exception, cancellation, and close paths release only resources they own and do so once.
  4. Inject boundary failures. Test callback exception, initialization failure, invalid handle, allocation failure, cancellation, output conversion, and partial close. Assert stage, typed result, cleanup count, and that the next allowed operation starts from a clean state.
  5. Keep diagnostics private and actionable. Expose error category and stage to the app while redacting prompts, media, credentials, and private paths. Use documented debug checks such as CheckJNI in controlled tests and verify release behavior separately.

Keep each transition observable. A failure should identify the stage, artifact, runtime, and recovery action without logging private user content.

Acceptance criteria

  • No C++ exception crosses from native code into managed code; pending Java exceptions are checked before subsequent JNI calls.
  • Each bridge operation has an explicit owner and cleanup contract for handles, references, buffers, and locks.
  • Injected failures produce typed, stage-specific results and leave no stale engine/session handle for later tasks.
  • User-visible and diagnostic errors exclude private input/output content unless a separately reviewed consented flow requires it.

Decision table

DimensionChoice or evidenceHow to interpret it
Raw native exception text to UIAvoidUnstable, potentially sensitive, and does not define resource cleanup or retry semantics.
Typed bridge resultMap known failure classes and stage to application stateSupports stable UI/recovery and test assertions while preserving useful classification.
Clear every pending exception blindlyDo not discard without a handling policyCan hide the original cause; check and handle or return according to the documented boundary.

Failure patterns to prevent

  • Throwing raw native text into UI
  • Continuing with a corrupted native context

Also prevent silent fallback, unpinned artifacts, missing cancellation, and conclusions that combine unlike configurations. Store unsuccessful runs alongside successful ones.

Minimum reproducibility record

LayerRecord
DeviceManufacturer, model, chipset, RAM class, operating-system build
SoftwareApplication version and git commit
ModelFamily, variant, revision, format, file length, hash, quantization
RuntimeName, revision, requested backend, observed backend evidence
WorkloadFixture revision, input hash, prompt hash, output policy
OutcomeCompleted, failed, cancelled, fallback, and privacy-safe diagnostics

Release checklist

  • ☐ The primary query is answered without an unsupported number.
  • ☐ Every artifact and runtime is pinned.
  • ☐ The representative task and failure policy are explicit.
  • ☐ Lifecycle, cancellation, cleanup, and fallback are tested.
  • ☐ User-content and network boundaries are documented.
  • ☐ Result wording applies only to the recorded configuration.
  • ☐ The page links to raw method or evidence when results are added.

Sources and related evidence

Chinese deployment and troubleshooting content is organized in the .奇连 AI 端侧专题.

Frequently asked questions

Can a C++ exception be thrown through JNI into Kotlin?

Android NDK guidance says C++ exceptions should not be thrown across the JNI transition boundary. Convert the failure into a documented status or managed exception policy instead.

Can native code keep calling JNI after a Java exception is pending?

Most JNI calls are not allowed while an exception is pending. Check the exception state, then handle or return according to the bridge contract before making further calls.

Does catching an exception guarantee the engine is safe to retry?

No. Cleanup and handle state must be verified separately. Test partial initialization, resource release, and the next permitted call before advertising retry as safe.