Skip to content

fix(nitrogen): look up Kotlin callbacks with a boxed return type - #1591

Open
giaBaoJS wants to merge 1 commit into
margelo:mainfrom
giaBaoJS:fix/kotlin-callback-primitive-return
Open

fix(nitrogen): look up Kotlin callbacks with a boxed return type#1591
giaBaoJS wants to merge 1 commit into
margelo:mainfrom
giaBaoJS:fix/kotlin-callback-primitive-return

Conversation

@giaBaoJS

@giaBaoJS giaBaoJS commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

What breaks

packages/nitrogen/src/syntax/kotlin/KotlinFunction.ts:142 builds the JNI signature of the generated JFunc_X::invoke() from the plain JNI type of the callback's return type. For Sync<() => number> that is double():

double invoke() const {
  static const auto method = javaClassStatic()->getMethod<double()>("invoke");
  auto __result = method(self());
  return __result;
}

The Kotlin half of the same callback is a fun interface that extends the Kotlin function type:

fun interface Func_double: () -> Double {
  override fun invoke(): Double
}

invoke overrides Function0<R>.invoke, and kotlinc cannot specialize a generic return type to a JVM primitive, so it keeps the boxed type. Parameters are specialized (kotlinc adds a (Ljava/lang/Object;)Ljava/lang/Object; bridge for them), return types are not.

fbjni resolves methods by exact descriptor, so the Android build does not catch this. GetMethodID finds nothing and the call throws NoSuchMethodError.

Ground truth

kotlinc 2.4.10 + javap -p -s, on the exact fun interface shape nitrogen generates:

Kotlin declaration what kotlinc emits what nitrogen looked up
fun interface F: () -> Double invoke ()Ljava/lang/Double; ()D
fun interface F: () -> Boolean invoke ()Ljava/lang/Boolean; ()Z
fun interface F: () -> Long invoke ()Ljava/lang/Long; ()J
fun interface F: () -> Unit invoke ()V ()V (already correct)
fun interface F: (Double) -> Unit invoke (D)V (D)V (already correct)
fun interface F: () -> String invoke ()Ljava/lang/String; same (already correct)

Unit is the exception: it maps to void, so a void callback was fine. Every other primitive return was broken.

When it fires

Only when the callback was implemented in Kotlin. A callback that came from JS is a Func_X_cxx, and KotlinCxxBridgedType unwraps that via getFunction() without ever touching JNI. Anything else falls into JNICallable<JFunc_X, R(Args...)>, which calls JFunc_X::invoke().

That is why CI never hit it: react-native-nitro-test used Func_double only as a parameter (callbackSync), never as a value handed from Kotlin to C++.

iOS is unaffected, Swift closures do not go through this path.

Fix

KotlinFunction.ts now boxes the return type of the invoke lookup for the primitive kinds, and unboxes __result with the existing parseFromKotlinToCpp(.., isBoxed) machinery:

double invoke() const {
  static const auto method = javaClassStatic()->getMethod<jni::local_ref<jni::JDouble>()>("invoke");
  auto __result = method(self());
  return __result->value();
}

std::optional<double> returns already emitted jni::local_ref<jni::JDouble>, so they were correct before and are untouched.

Test

One method on SharedTestObjectProps, in the "Sync funcs" block next to the existing callbackSync:

getSyncNumberCallback(): Sync<() => number>

plus one assertion in example/src/getTests.ts, so the Harness workflows cover it on both platforms. It reuses the Func_double specialization that callbackSync already generates, this time in the return direction, which is the direction that was broken.

How I proved it

I compiled the generated Kotlin (nitrogen/generated/android/kotlin, minus the ViewManagers, which need React Native on the classpath) with kotlinc, then checked every getField / getMethod lookup in nitrogen/generated/android/c++ against javap -p -s on the resulting class files.

Before the fix, one lookup did not exist:

('JFunc_double.hpp', 'com.margelo.nitro.test.Func_double', 'invoke', 'Method', '()D', ['()Ljava/lang/Double;', '()Ljava/lang/Object;'])
1 signature problems

Resolving that descriptor the way GetMethodID does:

MISSING  com.margelo.nitro.test.Func_double.invoke()D
FOUND    com.margelo.nitro.test.Func_double.invoke()Ljava/lang/Double;

After the fix, all 339 JNI names and descriptors emitted for react-native-nitro-test match the compiler, with no exceptions:

0 signature problems

I also checked the fix is not vacuous by mutating it in the other direction (boxing to jni::JLong instead of jni::JDouble), which the same check catches as ()Ljava/lang/Long; vs ()Ljava/lang/Double;.

Not covered

UInt64 callbacks are broken in a different way and are out of scope here: ULong is a value class, so kotlinc mangles the whole method name (invoke-s-VKNKU for a ULong return, invoke-VKZWuLQ for a ULong parameter). Boxing does not help there, so I left that path exactly as it was. Happy to open a separate issue for it.

Checks

  • bun specs run, generated files committed. Apart from the new method, the only generated change is JFunc_double.hpp and some include reordering.
  • bun run build, bun typecheck, bun lint, bun lint-cpp, bun lint-swift, bun lint-kotlin: all clean, no files changed by the formatters.
  • I could not run the Harness suite here (no Android device), so the new runtime assertion is verified by CI, not locally. The compiler evidence above is what I verified locally.

@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
nitro-docs Skipped Skipped Sep 5, 2026 3:59pm UTC

Request Review

@mrousavy

mrousavy commented Sep 5, 2026

Copy link
Copy Markdown
Member

We should avoid boxing for performance reasons. Maybe we can add a (private?) method called invokeDirect(..), and invoke(): Double would call invokeDirect if it is a primitive. I'll think about this

@mrousavy

mrousavy commented Sep 5, 2026

Copy link
Copy Markdown
Member

Probably this PR is fine to be merged at first, and then a follow-up PR to make it faster (with the invokeDirect(..)) - that splits it up nicely and atomically. Can you do that?

@mrousavy

mrousavy commented Sep 5, 2026

Copy link
Copy Markdown
Member

I am also not sure if that even makes things faster, I think that needs to actually be benchmarked. Not sure what the JVM does under the hood in this case.

A generated `fun interface Func_X: (..) -> R` overrides `FunctionN.invoke`,
whose return type is a generic, so kotlinc keeps the boxed type in the JVM
signature. Parameters are specialized to primitives, return types are not.

The generated JNI lookup used the primitive signature (`()D` for `() => number`),
which does not exist on the class, so the first C++ call into a Kotlin-implemented
callback that returns a value throws NoSuchMethodError.
@giaBaoJS
giaBaoJS force-pushed the fix/kotlin-callback-primitive-return branch from 6e4d9c8 to 30bff40 Compare September 5, 2026 15:59
@giaBaoJS

giaBaoJS commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Yes, I can do that. This PR stays as the correctness fix, and I will open a separate PR for invokeDirect once this one is in.

On your third point, I did not want to assume boxing is expensive, so I measured what the JVM does with it. Method: OpenJDK 17 on arm64 macOS, an interface returning Double against one returning double, 20M calls after warmup, allocation from ThreadMXBean.getThreadAllocatedBytes, time as best of 7 reps.

                       alloc       time
primitive double       0 B/op      0.494 ns/op
boxed, non-escaping    0 B/op      0.512 ns/op
boxed, escaping       24 B/op      1.595 ns/op

Two things come out of that:

  • Double has no valueOf cache, unlike Integer, so a boxed return is always a fresh allocation.
  • Escape analysis removes that allocation entirely when the box does not escape. It only costs anything when it does, which is our case: the box is handed to C++ as a jobject, so it cannot be scalar replaced.

So the ceiling for invokeDirect looks like roughly 1 ns and 24 bytes per call. That is HotSpot on a desktop with no JNI in the loop. I have not measured the path that actually matters, which is ART on device plus the JNI crossing and a local ref for the returned object, so I would not carry these numbers over. The follow-up will lead with a benchmark there rather than with an assumption.

One thing I ran into while looking at it: fun interface Func_X: () -> R can only have one abstract member, so invokeDirect cannot just be added next to invoke. And Func_X_java wraps a () -> Double, which is a Function0<Double> and boxes on its own before the JNI call happens at all. Avoiding the box end to end may mean the generated interface stops extending FunctionN, not just gaining a method. I will work that out in the follow-up.

Rebased on main, this is mergeable again.

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