From 98e44c01f2c0c82bfdb21f485152a046bb581c1b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 8 Nov 2026 17:16:25 -0300 Subject: [PATCH 1/2] test: pin how JS callback results convert to each primitive return type A JS implementation returns 33 kinds of values (whole/fractional/out-of-range numbers, NaN/Infinity, booleans, strings, null/undefined, Number/Boolean objects, the numeric cast markers, plain objects) through methods returning each Java primitive type; the spec asserts the value or exception class the generated binding produces, as captured from the boxed conversion. --- test-app/app/src/main/assets/app/mainpage.js | 1 + .../app/tests/testPrimitiveCallbackReturns.js | 108 ++++++++++++++++++ .../com/tns/tests/PrimitiveReturnTest.java | 53 +++++++++ 3 files changed, 162 insertions(+) create mode 100644 test-app/app/src/main/assets/app/tests/testPrimitiveCallbackReturns.js create mode 100644 test-app/app/src/main/java/com/tns/tests/PrimitiveReturnTest.java diff --git a/test-app/app/src/main/assets/app/mainpage.js b/test-app/app/src/main/assets/app/mainpage.js index dbde19a78..4bf18777f 100644 --- a/test-app/app/src/main/assets/app/mainpage.js +++ b/test-app/app/src/main/assets/app/mainpage.js @@ -68,6 +68,7 @@ require("./tests/shared-array-buffer-test"); require("./tests/dex-interface-implementation"); require("./tests/testClassForNameDiscovery"); require("./tests/testInterfaceImplementation"); +require("./tests/testPrimitiveCallbackReturns"); require("./tests/testRuntimeImplementedAPIs"); require("./tests/testsInstanceOfOperator"); require("./tests/testProxyReceivers"); diff --git a/test-app/app/src/main/assets/app/tests/testPrimitiveCallbackReturns.js b/test-app/app/src/main/assets/app/tests/testPrimitiveCallbackReturns.js new file mode 100644 index 000000000..d206de91f --- /dev/null +++ b/test-app/app/src/main/assets/app/tests/testPrimitiveCallbackReturns.js @@ -0,0 +1,108 @@ +describe("Primitive return values of JS callbacks", function () { + var current; + var impl = new com.tns.tests.PrimitiveReturnTest.Returns({ + z: function () { return current(); }, + b: function () { return current(); }, + c: function () { return current(); }, + s: function () { return current(); }, + i: function () { return current(); }, + j: function () { return current(); }, + f: function () { return current(); }, + d: function () { return current(); } + }); + + // Numbers at or beyond +-2^63 are left out: the boxed conversion casts them + // to int64, which is undefined and differs between arm64 and x86_64. + var values = { + "1": function () { return 1; }, + "-1": function () { return -1; }, + "0": function () { return 0; }, + "-0": function () { return -0; }, + "2^31": function () { return 2147483648; }, + "-2^31-1": function () { return -2147483649; }, + "2^40": function () { return 1099511627776; }, + "2^60": function () { return 1152921504606846976; }, + "1.5": function () { return 1.5; }, + "-1.5": function () { return -1.5; }, + "1e-40": function () { return 1e-40; }, + "3.5e38": function () { return 3.5e38; }, + "NaN": function () { return NaN; }, + "Infinity": function () { return Infinity; }, + "-Infinity": function () { return -Infinity; }, + "-2.5": function () { return -2.5; }, + "true": function () { return true; }, + "false": function () { return false; }, + "'1'": function () { return "1"; }, + "'a'": function () { return "a"; }, + "null": function () { return null; }, + "undefined": function () { return undefined; }, + "Number(5)": function () { return new Number(5); }, + "Number(2.5)": function () { return new Number(2.5); }, + "Boolean(true)": function () { return new Boolean(true); }, + "Boolean(false)": function () { return new Boolean(false); }, + "long(5)": function () { return long(5); }, + "byte(2)": function () { return byte(2); }, + "short(3)": function () { return short(3); }, + "char('a')": function () { return char("a"); }, + "float(1.5)": function () { return float(1.5); }, + "double(2)": function () { return double(2); }, + "{}": function () { return {}; } + }; + + // Captured from the boxed conversion; columns are z b c s i j f d. + var expected = { + "0": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:0 ok:0 ex:ClassCastException ex:ClassCastException", + "1": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:1 ok:1 ex:ClassCastException ex:ClassCastException", + "-1": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:-1 ok:-1 ex:ClassCastException ex:ClassCastException", + "-0": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:0 ok:0 ex:ClassCastException ex:ClassCastException", + "2^31": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:2147483648 ex:ClassCastException ex:ClassCastException", + "-2^31-1": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:-2147483649 ex:ClassCastException ex:ClassCastException", + "2^40": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:1099511627776 ex:ClassCastException ex:ClassCastException", + "2^60": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:1152921504606846976 ex:ClassCastException ex:ClassCastException", + "1.5": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:1.5 ok:1.5", + "-1.5": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:-1.5", + "1e-40": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:1.0E-40", + "3.5e38": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:3.5E38", + "NaN": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:NaN", + "Infinity": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:Infinity", + "-Infinity": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:-Infinity", + "-2.5": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:-2.5", + "true": "ok:true ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "false": "ok:false ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "'1'": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "'a'": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "null": "ok:false ok:0 ok:0 ok:0 ok:0 ok:0 ok:0.0 ok:0.0", + "undefined": "ok:false ok:0 ok:0 ok:0 ok:0 ok:0 ok:0.0 ok:0.0", + "Number(5)": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:5 ok:5 ex:ClassCastException ex:ClassCastException", + "Number(2.5)": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:2.5 ok:2.5", + "Boolean(true)": "ok:true ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "Boolean(false)": "ok:true ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "long(5)": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:5 ex:ClassCastException ex:ClassCastException", + "byte(2)": "ex:ClassCastException ok:2 ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "short(3)": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:3 ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "char('a')": "ex:ClassCastException ex:ClassCastException ok:97 ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException", + "float(1.5)": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:1.5 ex:ClassCastException", + "double(2)": "ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ex:ClassCastException ok:2.0", + "{}": "ok:false ok:0 ok:0 ok:0 ok:0 ok:0 ok:0.0 ok:0.0" + }; + + var kinds = ["z", "b", "c", "s", "i", "j", "f", "d"]; + + function matrix() { + var result = {}; + Object.keys(values).forEach(function (name) { + current = values[name]; + result[name] = kinds.map(function (kind) { + return com.tns.tests.PrimitiveReturnTest.call(impl, kind); + }).join(" "); + }); + return result; + } + + it("converts every JS value the same way for each primitive return type", function () { + var actual = matrix(); + Object.keys(values).forEach(function (name) { + expect(name + " | " + actual[name]).toBe(name + " | " + expected[name]); + }); + }); +}); diff --git a/test-app/app/src/main/java/com/tns/tests/PrimitiveReturnTest.java b/test-app/app/src/main/java/com/tns/tests/PrimitiveReturnTest.java new file mode 100644 index 000000000..284084e14 --- /dev/null +++ b/test-app/app/src/main/java/com/tns/tests/PrimitiveReturnTest.java @@ -0,0 +1,53 @@ +package com.tns.tests; + +/** + * Calls each primitive-returning method of a JS implementation and reports either the + * returned value or the class of what was thrown, so a spec can pin how JS return values + * convert to each primitive type. + */ +public class PrimitiveReturnTest { + public interface Returns { + boolean z(); + + byte b(); + + char c(); + + short s(); + + int i(); + + long j(); + + float f(); + + double d(); + } + + public static String call(Returns r, String kind) { + try { + switch (kind) { + case "z": + return "ok:" + r.z(); + case "b": + return "ok:" + r.b(); + case "c": + return "ok:" + (int) r.c(); + case "s": + return "ok:" + r.s(); + case "i": + return "ok:" + r.i(); + case "j": + return "ok:" + r.j(); + case "f": + return "ok:" + r.f(); + case "d": + return "ok:" + r.d(); + default: + throw new IllegalArgumentException(kind); + } + } catch (Throwable t) { + return "ex:" + t.getClass().getSimpleName(); + } + } +} From af2fbdd11f5286d182efe0bc9a21ebe803c22965 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Sun, 8 Nov 2026 17:20:43 -0300 Subject: [PATCH 2/2] perf: return primitive callback results as raw bits A JS callback result for a primitive Java return type went through JsArgToArrayConverter: a jobject[1] allocation, a JNI NewObject of the wrapper, a local-ref round trip, then the generated binding unboxed it. For boolean/int/long/float/double the native side now writes the value as raw bits into a long[2] the caller passes, and Java boxes it with valueOf. Only the cases the boxed conversion maps to exactly the declared wrapper take this path; every other value, including those that end in a ClassCastException or a defaulted null today, still goes through the boxed conversion, so the results are unchanged (pinned by the previous commit's spec). --- test-app/runtime/src/main/cpp/Runtime.cpp | 92 ++++++++++++++++++- test-app/runtime/src/main/cpp/Runtime.h | 2 +- .../runtime/src/main/cpp/com_tns_Runtime.cpp | 4 +- .../src/main/java/com/tns/Runtime.java | 39 +++++++- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/test-app/runtime/src/main/cpp/Runtime.cpp b/test-app/runtime/src/main/cpp/Runtime.cpp index 33eabea5b..7dca92805 100644 --- a/test-app/runtime/src/main/cpp/Runtime.cpp +++ b/test-app/runtime/src/main/cpp/Runtime.cpp @@ -54,6 +54,11 @@ #include "include/libplatform/libplatform.h" #include "sys/system_properties.h" +#include +#include +#include +#include + #ifdef APPLICATION_IN_DEBUG // #include "NetworkDomainCallbackHandlers.h" #include "JsV8InspectorClient.h" @@ -497,10 +502,86 @@ jobject Runtime::RunScript(JNIEnv* _env, jobject obj, jstring scriptFile) { return res; } +/* + * The primitive-returning cases of JsArgToArrayConverter::ConvertArg that box + * into exactly the declared wrapper type, which is all the generated binding's + * cast accepts. Everything else keeps the boxed conversion, including the + * values it turns into a ClassCastException or a defaulted null. + */ +static bool TryGetPrimitiveReturnBits(Isolate* isolate, + const Local& value, Type type, + jlong& bits) { + if (value.IsEmpty()) { + return false; + } + + if (type == Type::Boolean) { + if (!value->IsBoolean() && !value->IsBooleanObject()) { + return false; + } + // ToBoolean, as the boxed path does: a Boolean object is always true. + bits = value->BooleanValue(isolate) ? 1 : 0; + return true; + } + + // Number objects are left to the boxed path: reading one calls valueOf, + // which must not run twice if this path then declines. + if (!value->IsNumber()) { + return false; + } + + if (type == Type::Int && value->IsInt32()) { + bits = value.As()->Value(); + return true; + } + + double d = value.As()->Value(); + // Beyond int64 the boxed path's double -> int64 cast is undefined. + constexpr double kTwoPow63 = 9223372036854775808.0; + if (std::isfinite(d) && std::fabs(d) >= kTwoPow63) { + return false; + } + bool isWholeNumber = + std::isfinite(d) && d == static_cast(static_cast(d)); + + switch (type) { + case Type::Int: + if (isWholeNumber && INT_MIN <= d && d <= INT_MAX) { + bits = static_cast(d); + return true; + } + return false; + case Type::Long: + if (isWholeNumber) { + bits = static_cast(d); + return true; + } + return false; + case Type::Float: + if (!isWholeNumber && FLT_MIN <= d && d <= FLT_MAX) { + float f = static_cast(d); + int32_t raw; + memcpy(&raw, &f, sizeof(raw)); + bits = raw; + return true; + } + return false; + case Type::Double: + if (!isWholeNumber) { + memcpy(&bits, &d, sizeof(bits)); + return true; + } + return false; + default: + return false; + } +} + jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, - const JavaCallArgs& args) { + const JavaCallArgs& args, + jlongArray primitiveResult) { SET_PROFILER_FRAME(); auto isolate = m_isolate; @@ -540,6 +621,15 @@ jobject Runtime::CallJSMethodNative(JNIEnv* _env, jobject obj, return nullptr; } + jlong bits; + if (primitiveResult != nullptr && + TryGetPrimitiveReturnBits(m_isolate, jsResult, static_cast(retType), + bits)) { + jlong out[2] = {bits, 1}; + _env->SetLongArrayRegion(primitiveResult, 0, 2, out); + return nullptr; + } + int classReturnType = retType; jobject javaObject = ConvertJsValueToJavaObject(env, jsResult, classReturnType); diff --git a/test-app/runtime/src/main/cpp/Runtime.h b/test-app/runtime/src/main/cpp/Runtime.h index 4a427525f..bc75dc697 100644 --- a/test-app/runtime/src/main/cpp/Runtime.h +++ b/test-app/runtime/src/main/cpp/Runtime.h @@ -172,7 +172,7 @@ class Runtime { void RunModule(const char *moduleName); void RunWorker(const std::string& filePath); jobject RunScript(JNIEnv* _env, jobject obj, jstring scriptFile); - jobject CallJSMethodNative(JNIEnv* _env, jobject obj, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, const JavaCallArgs& args); + jobject CallJSMethodNative(JNIEnv* _env, jobject obj, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, const JavaCallArgs& args, jlongArray primitiveResult); void CreateJSInstanceNative(JNIEnv* _env, jobject obj, jobject javaObject, jint javaObjectID, jstring className); jint GenerateNewObjectId(JNIEnv* env, jobject obj); void AdjustAmountOfExternalAllocatedMemory(); diff --git a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp index f4f0386c0..82ac48fcf 100644 --- a/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_Runtime.cpp @@ -194,7 +194,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_runScript(JNIEnv* _env, jobjec return result; } -extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, jintArray argTypes, jlongArray argValues, jobjectArray argRefs) { +extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _env, jobject obj, jint runtimeId, jint javaObjectID, jstring methodName, jint retType, jboolean isConstructor, jintArray argTypes, jlongArray argValues, jobjectArray argRefs, jlongArray primitiveResult) { JEnv::EntryScope envScope(_env); jobject result = nullptr; @@ -211,7 +211,7 @@ extern "C" JNIEXPORT jobject Java_com_tns_Runtime_callJSMethodNative(JNIEnv* _en v8::Context::Scope context_scope(context); try { - result = runtime->CallJSMethodNative(_env, obj, javaObjectID, methodName, retType, isConstructor, JavaCallArgs{argTypes, argValues, argRefs}); + result = runtime->CallJSMethodNative(_env, obj, javaObjectID, methodName, retType, isConstructor, JavaCallArgs{argTypes, argValues, argRefs}, primitiveResult); } catch (NativeScriptException& e) { e.ReThrowToJava(); } catch (std::exception e) { diff --git a/test-app/runtime/src/main/java/com/tns/Runtime.java b/test-app/runtime/src/main/java/com/tns/Runtime.java index c21c02b5f..5e1d5e302 100644 --- a/test-app/runtime/src/main/java/com/tns/Runtime.java +++ b/test-app/runtime/src/main/java/com/tns/Runtime.java @@ -43,7 +43,7 @@ private native void initNativeScript(int runtimeId, String filesPath, String nat private native Object runScript(int runtimeId, String filePath) throws NativeScriptException; - private native Object callJSMethodNative(int runtimeId, int javaObjectID, String methodName, int retType, boolean isConstructor, int[] argTypes, long[] argValues, Object[] argRefs) throws NativeScriptException; + private native Object callJSMethodNative(int runtimeId, int javaObjectID, String methodName, int retType, boolean isConstructor, int[] argTypes, long[] argValues, Object[] argRefs, long[] primitiveResult) throws NativeScriptException; private native void createJSInstanceNative(int runtimeId, Object javaObject, int javaObjectID, String canonicalName); @@ -1409,8 +1409,10 @@ private Object dispatchCallJSMethodNative(final Object javaObject, final String if (enableMultithreadedJavascript || isWorkThread) { int javaObjectID = resolveJavaObjectID(javaObject, methodName); PackagedArgs packagedArgs = packageArgs(tmpArgs); + long[] primitiveResult = newPrimitiveResult(returnType); try { - ret = callJSMethodNative(getRuntimeId(), javaObjectID, methodName, returnType, isConstructor, packagedArgs.types, packagedArgs.values, packagedArgs.refs); + ret = callJSMethodNative(getRuntimeId(), javaObjectID, methodName, returnType, isConstructor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult); + ret = takePrimitiveResult(ret, returnType, primitiveResult); } catch (NativeScriptException e) { // With multithreaded JS the owner can be torn down between the // routing check and the native call, which then throws @@ -1449,7 +1451,8 @@ public void run() { return; } final PackagedArgs packagedArgs = packageArgs(tmpArgs); - arr[0] = callJSMethodNative(getRuntimeId(), javaObjectID, methodName, returnType, isCtor, packagedArgs.types, packagedArgs.values, packagedArgs.refs); + final long[] primitiveResult = newPrimitiveResult(returnType); + arr[0] = takePrimitiveResult(callJSMethodNative(getRuntimeId(), javaObjectID, methodName, returnType, isCtor, packagedArgs.types, packagedArgs.values, packagedArgs.refs, primitiveResult), returnType, primitiveResult); } catch (NativeScriptException e) { if (discardUncaughtJsExceptions && !e.isEscapedFromJs()) { String errorMessage = "Error on \"" + Thread.currentThread().getName() + "\" thread for callJSMethodNative\n"; @@ -1533,6 +1536,36 @@ private static Object discardCall(String methodName, Class retType, String re : null; } + /* + * For return types the native side can hand back as raw bits instead of a + * boxed object: it writes {bits, 1} here and returns null, or leaves the + * array untouched and returns the boxed value as before. + */ + private static long[] newPrimitiveResult(int returnType) { + boolean raw = returnType == TypeIDs.Boolean || returnType == TypeIDs.Int || returnType == TypeIDs.Long + || returnType == TypeIDs.Float || returnType == TypeIDs.Double; + return raw ? new long[2] : null; + } + + private static Object takePrimitiveResult(Object nativeResult, int returnType, long[] primitiveResult) { + if (primitiveResult == null || primitiveResult[1] == 0) { + return nativeResult; + } + + long bits = primitiveResult[0]; + if (returnType == TypeIDs.Boolean) { + return bits != 0; + } else if (returnType == TypeIDs.Int) { + return (int) bits; + } else if (returnType == TypeIDs.Long) { + return bits; + } else if (returnType == TypeIDs.Float) { + return Float.intBitsToFloat((int) bits); + } else { + return Double.longBitsToDouble(bits); + } + } + private static Object defaultPrimitiveValue(Class type) { if (type == boolean.class) { return Boolean.FALSE;