Skip to content

Don't JNI-bridge generic @inline(__always) functions - #260

Merged
marcprux merged 2 commits into
skiptools:mainfrom
vincentborko:pr/bridge-skip-inline-reified
Jul 24, 2026
Merged

Don't JNI-bridge generic @inline(__always) functions#260
marcprux merged 2 commits into
skiptools:mainfrom
vincentborko:pr/bridge-skip-inline-reified

Conversation

@vincentborko

@vincentborko vincentborko commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Motivation

A generic @inline(__always) function on a bridged type is emitted as a Kotlin inline fun <reified T>, which has no JVM-callable method. The bridge generator nonetheless emits a getMethodID(...)! / getStaticMethodID(...)! JNI lookup for it, so the force-unwrap traps at class load — Fatal error: Unexpectedly found nil while unwrapping an Optional value — for any native-Swift-on-Android caller (a #if !SKIP_BRIDGE app type calling through the real JNI edge; swift test's JVM-native path never trips it because it inlines the body).

It reproduces whenever the reified method lives on an open class that has a subclass: the method is then lifted to a top-level inline fun <reified T> extension and bridged via getStaticMethodID. This is the root cause behind skiptools/skip-firebase#81 (originally worked around by switching a manual // SKIP DECLARE: inline fun <reified T> to @inline(__always)) and the still-open skiptools/skip-firebase#91, where DocumentSnapshot.decoded() (it has subclass QueryDocumentSnapshot) crashes again while the subclass-free FirestoreDecoder keeps working.

Fix

In KotlinFunctionDeclaration.checkBridgable(...) -> FunctionBridgable?, return nil for a generic @inline(__always) function so it is not JNI-bridged. Native-Swift callers use the @inline(__always) Swift body directly (inlined); Kotlin callers use the inline fun <reified T>. An inline reified function was never JNI-callable to begin with, so nothing is lost — the change only removes a lookup that could only ever trap.

// A generic `@inline(__always)` function is emitted as a Kotlin `inline fun <reified T>` ...
// An inline reified function has no JVM-callable method, so a JNI getMethodID lookup for it
// returns nil and the generated bridge's force-unwrap traps at class load ...
guard !(attributes.contains(.inlineAlways) && !generics.isEmpty) else {
    return nil
}

Also covers the manual // SKIP DECLARE: path (second commit)

A hand-written // SKIP DECLARE: ... inline fun <reified T> ... produces the same JVM-uncallable Kotlin but carries no structured attribute — so the attribute guard alone does not fully close #91. DocumentSnapshot.decoded() (now un-bridged by the attribute guard) inlines a call to FirestoreDecoder.decode(from:), which uses exactly this override and is still emitting a trapping getMethodID(name: "decode", ...)! in a real build artifact — the crash reproduces one frame deeper. The second commit therefore also bails out of bridging when the declaration override is itself an inline reified function. (Currently a textual match on the override — happy to switch to a more structured signal if you'd prefer.)

Testing

  • BridgeInlineReifiedTests has two cases, both asserting the generated bridge contains the Kotlin inline fun <reified T> but no methodID lookup for the method: testReifiedInlineMethodIsNotBridged (the @inline(__always) path, Allow use of #warning and #error in Xcode #91 shape on an open class with a subclass) and testSkipDeclareReifiedMethodIsNotBridged (the // SKIP DECLARE: path, skip-firebase's FirestoreDecoder.decode(from:) shape).
  • Full swift test suite green (922 tests, 0 failures).

Refs skiptools/skip-firebase#81, skiptools/skip-firebase#91

A generic `@inline(__always)` function is emitted as a Kotlin
`inline fun <reified T>`, which has no JVM-callable method. The bridge
generator nonetheless emitted a `getMethodID(...)!` JNI lookup for it,
so the force-unwrap trapped at class load ("Unexpectedly found nil while
unwrapping an Optional value") for any native-Swift-on-Android caller.

This reproduces whenever the reified method lives on an `open` class that
has a subclass (the method is then lifted to a top-level `inline fun
<reified T>` extension and bridged via `getStaticMethodID`) — the root
cause behind skiptools/skip-firebase#81 and the still-open skiptools#91.

Return nil from `KotlinFunctionDeclaration.checkBridgable` for a generic
`@inline(__always)` function so it is not JNI-bridged. Native-Swift
callers use the `@inline(__always)` Swift body directly (inlined); Kotlin
callers use the `inline fun <reified T>`. An inline reified function was
never JNI-callable to begin with, so nothing is lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@cla-bot cla-bot Bot added the cla-signed label Jul 24, 2026
A `// SKIP DECLARE: ... inline fun <reified T> ...` override produces the
same JVM-uncallable Kotlin as @inline(__always) but carries no structured
attribute, so the attribute guard alone left the crash reachable via
skip-firebase's FirestoreDecoder.decode(from:) (called from the inlined
body of DocumentSnapshot.decoded()) — i.e. skiptools#91 was not fully closed.
Also bail out of bridging when the declaration override is an inline
reified function.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@marcprux
marcprux merged commit 55ed197 into skiptools:main Jul 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants