Skip to content

Commit 302691c

Browse files
committed
feat: interop.escapeException (forward original Java exceptions to native callers)
Ports the identity-preservation half of iOS's interop.escapeException (NativeScript/ios#409). The escape direction itself is already Android's default - a JS throw in a native-invoked override surfaces to the Java caller as a real com.tns.NativeScriptException - but a rethrown *Java* exception lost its identity: ReThrowToJava wrapped it, so the caller's catch of the concrete type (e.g. IOException) never matched. Now, mirroring iOS: - New `interop` global (per isolate, workers included) with interop.escapeException(x): returns a fresh JS Error branded via a private symbol whose payload carries the original Java throwable when x is/carries one (nativeException), or {name, message, stack} synthesis info otherwise. Idempotent; TypeError with no arguments. - At the JS->Java boundary, a branded error carrying a Java throwable is rethrown UNWRAPPED (env.Throw of the original object), so a native `catch (IOException e)` above the caller matches and Throwable identity is preserved. JNI does not enforce checked-exception declarations, so any Throwable propagates. - Branded errors bypass discardUncaughtJsExceptions (which only handles com.tns.NativeScriptException) - an explicit forward request, matching iOS semantics where branded escapes ignore the discard flag. - Unbranded throws keep today's semantics exactly (wrapper with the original as cause); branded plain JS errors keep the default NativeScriptException escape shape. Testing: 7 new specs (brand semantics + boundary round-trips through a new EscapeExceptionTest fixture, asserting the caller catches the original java.io.IOException by identity, and that unbranded/plain behavior is unchanged). Full suite green on an API 35 arm64 emulator.
1 parent 2044ebf commit 302691c

8 files changed

Lines changed: 400 additions & 0 deletions

File tree

test-app/app/src/main/assets/app/mainpage.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ require('./tests/testPerformanceNow');
7676
require('./tests/testQueueMicrotask');
7777
require('./tests/testErrorEvents');
7878
require('./tests/testUnhandledRejections');
79+
require('./tests/testEscapeException');
7980
require("./tests/testConcurrentAccess");
8081

8182
require("./tests/testESModules.mjs");
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
describe("interop.escapeException", function () {
2+
it("exists on the interop global", function () {
3+
expect(typeof interop).toBe("object");
4+
expect(typeof interop.escapeException).toBe("function");
5+
});
6+
7+
it("returns a throwable Error preserving the message", function () {
8+
var wrapped = interop.escapeException(new Error("boom"));
9+
expect(wrapped instanceof Error).toBe(true);
10+
expect(wrapped.message).toBe("boom");
11+
12+
var caught = null;
13+
try {
14+
throw wrapped;
15+
} catch (e) {
16+
caught = e;
17+
}
18+
expect(caught).toBe(wrapped);
19+
});
20+
21+
it("throws TypeError when called with no arguments", function () {
22+
expect(function () {
23+
interop.escapeException();
24+
}).toThrowError(TypeError);
25+
});
26+
27+
it("is idempotent (double-wrap returns the same object)", function () {
28+
var once = interop.escapeException(new Error("once"));
29+
var twice = interop.escapeException(once);
30+
expect(twice).toBe(once);
31+
});
32+
33+
it("rethrows the ORIGINAL Java exception to a native caller", function () {
34+
var caught = null;
35+
try {
36+
com.tns.tests.EscapeExceptionTest.throwIOException();
37+
} catch (e) {
38+
caught = e;
39+
}
40+
expect(caught).not.toBeNull();
41+
expect(caught.nativeException).toBeDefined();
42+
43+
var runnable = new java.lang.Runnable({
44+
run: function () {
45+
throw interop.escapeException(caught);
46+
}
47+
});
48+
var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable);
49+
50+
// The native caller caught the original java.io.IOException - not a
51+
// com.tns.NativeScriptException wrapper - so a concrete
52+
// `catch (IOException e)` in native code would match.
53+
expect(ret).not.toBeNull();
54+
expect(ret.getClass().getName()).toBe("java.io.IOException");
55+
expect(ret.getMessage()).toBe("original-io-exception");
56+
expect(ret.equals(caught.nativeException)).toBe(true);
57+
58+
// JS is still alive after the escape round-trip.
59+
expect(1 + 1).toBe(2);
60+
});
61+
62+
it("an unbranded rethrow keeps today's wrapping semantics", function () {
63+
var caught = null;
64+
try {
65+
com.tns.tests.EscapeExceptionTest.throwIOException();
66+
} catch (e) {
67+
caught = e;
68+
}
69+
expect(caught).not.toBeNull();
70+
71+
var runnable = new java.lang.Runnable({
72+
run: function () {
73+
throw caught;
74+
}
75+
});
76+
var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable);
77+
78+
// Without the brand the caller receives the NativeScriptException
79+
// wrapper, with the original exception preserved as its cause.
80+
expect(ret).not.toBeNull();
81+
expect(ret.getClass().getName()).toBe("com.tns.NativeScriptException");
82+
expect(ret.getCause().equals(caught.nativeException)).toBe(true);
83+
});
84+
85+
it("a branded plain JS error escapes with the default NativeScriptException shape", function () {
86+
var runnable = new java.lang.Runnable({
87+
run: function () {
88+
throw interop.escapeException(new Error("plain-escape"));
89+
}
90+
});
91+
var ret = com.tns.tests.EscapeExceptionTest.invokeCatchingThrowable(runnable);
92+
93+
// No underlying Java throwable to unwrap, so the standard escape path
94+
// applies: the caller gets a com.tns.NativeScriptException carrying
95+
// the JS message.
96+
expect(ret).not.toBeNull();
97+
expect(ret.getClass().getName()).toBe("com.tns.NativeScriptException");
98+
expect(ret.getMessage()).toContain("plain-escape");
99+
});
100+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.tns.tests;
2+
3+
public class EscapeExceptionTest {
4+
public static void throwIOException() throws java.io.IOException {
5+
throw new java.io.IOException("original-io-exception");
6+
}
7+
8+
/*
9+
* Invokes a callback implemented in JS and returns whatever Throwable
10+
* escapes it (or null). Catching Throwable (rather than a concrete type)
11+
* lets the tests assert exactly which exception class crossed the
12+
* JS->Java boundary.
13+
*/
14+
public static Throwable invokeCatchingThrowable(Runnable runnable) {
15+
try {
16+
runnable.run();
17+
return null;
18+
} catch (Throwable t) {
19+
return t;
20+
}
21+
}
22+
}

test-app/runtime/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ add_library(
107107
src/main/cpp/Events.cpp
108108
src/main/cpp/FieldAccessor.cpp
109109
src/main/cpp/File.cpp
110+
src/main/cpp/Interop.cpp
110111
src/main/cpp/IsolateDisposer.cpp
111112
src/main/cpp/JEnv.cpp
112113
src/main/cpp/DesugaredInterfaceCompanionClassNameResolver.cpp
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
#include "Interop.h"
2+
3+
#include <string>
4+
5+
#include "ArgConverter.h"
6+
#include "JniLocalRef.h"
7+
#include "NativeScriptException.h"
8+
#include "ObjectManager.h"
9+
#include "Runtime.h"
10+
#include "V8StringConstants.h"
11+
12+
using namespace std;
13+
using namespace tns;
14+
using namespace v8;
15+
16+
/*
17+
* The brand is a per-isolate private symbol: invisible to JS property
18+
* enumeration, so app code can neither observe nor forge it. Its value is the
19+
* escape payload - `{nativeException}` when the branded error carries an
20+
* original Java throwable, `{name, message, stack}` synthesis info otherwise
21+
* (mirroring the iOS runtime's payload shape).
22+
*/
23+
static Local<Private> GetBrand(Isolate* isolate) {
24+
return Private::ForApi(
25+
isolate,
26+
ArgConverter::ConvertToV8String(isolate, "tns::escapedExceptionPayload"));
27+
}
28+
29+
/*
30+
* Resolves the JS wrapper of a Java throwable carried by `value` - `value`
31+
* itself when it wraps a java.lang.Throwable, or its `nativeException` when
32+
* `value` is an Error carrying one. Empty handle otherwise.
33+
*/
34+
static Local<Value> GetWrappedJavaThrowable(Local<Context> context, Local<Value> value) {
35+
auto isolate = context->GetIsolate();
36+
37+
auto isWrappedThrowable = [&](Local<Value> v) -> bool {
38+
if (v.IsEmpty() || !v->IsObject()) {
39+
return false;
40+
}
41+
auto objectManager = Runtime::GetObjectManager(isolate);
42+
auto javaObj = objectManager->GetJavaObjectByJsObject(v.As<Object>());
43+
if (javaObj.IsNull()) {
44+
return false;
45+
}
46+
JEnv env;
47+
JniLocalRef objClass(env.GetObjectClass(javaObj));
48+
jclass throwableClass = env.FindClass("java/lang/Throwable");
49+
return env.IsAssignableFrom(objClass, throwableClass) == JNI_TRUE;
50+
};
51+
52+
if (isWrappedThrowable(value)) {
53+
return value;
54+
}
55+
if (value->IsObject()) {
56+
Local<Value> nativeExc;
57+
if (value.As<Object>()
58+
->Get(context, V8StringConstants::GetNativeException(isolate))
59+
.ToLocal(&nativeExc) &&
60+
isWrappedThrowable(nativeExc)) {
61+
return nativeExc;
62+
}
63+
}
64+
return Local<Value>();
65+
}
66+
67+
static void EscapeExceptionCallback(const FunctionCallbackInfo<Value>& info) {
68+
auto isolate = info.GetIsolate();
69+
auto context = isolate->GetCurrentContext();
70+
71+
if (info.Length() < 1) {
72+
isolate->ThrowException(Exception::TypeError(ArgConverter::ConvertToV8String(
73+
isolate,
74+
"interop.escapeException requires 1 argument, but only 0 present.")));
75+
return;
76+
}
77+
78+
Local<Value> x = info[0];
79+
Local<Private> brand = GetBrand(isolate);
80+
81+
// Idempotent: an already-branded value is returned unchanged.
82+
if (x->IsObject() &&
83+
x.As<Object>()->HasPrivate(context, brand).FromMaybe(false)) {
84+
info.GetReturnValue().Set(x);
85+
return;
86+
}
87+
88+
// Derive the message string, preferring x.message when x is an Error.
89+
string message;
90+
bool xIsObject = x->IsObject();
91+
Local<String> strVal;
92+
if (xIsObject) {
93+
Local<Value> msgVal;
94+
if (x.As<Object>()
95+
->Get(context, ArgConverter::ConvertToV8String(isolate, "message"))
96+
.ToLocal(&msgVal) &&
97+
!msgVal->IsNullOrUndefined() && msgVal->ToString(context).ToLocal(&strVal)) {
98+
message = ArgConverter::ConvertToString(strVal);
99+
} else if (x->ToString(context).ToLocal(&strVal)) {
100+
message = ArgConverter::ConvertToString(strVal);
101+
}
102+
} else if (x->ToString(context).ToLocal(&strVal)) {
103+
message = ArgConverter::ConvertToString(strVal);
104+
}
105+
106+
// The returned value is a real JS Error so `throw interop.escapeException(x)`
107+
// behaves like a normal throw in pure-JS paths.
108+
auto errObj = Exception::Error(ArgConverter::ConvertToV8String(isolate, message))
109+
.As<Object>();
110+
111+
// Copy stack from x when it is an Error carrying one.
112+
string stack;
113+
if (xIsObject) {
114+
Local<Value> stackVal;
115+
if (x.As<Object>()
116+
->Get(context, V8StringConstants::GetStack(isolate))
117+
.ToLocal(&stackVal) &&
118+
stackVal->IsString()) {
119+
stack = ArgConverter::ConvertToString(stackVal.As<String>());
120+
errObj->Set(context, V8StringConstants::GetStack(isolate), stackVal)
121+
.FromMaybe(false);
122+
}
123+
}
124+
125+
// Build the branded payload: the original Java throwable when x carries
126+
// one, otherwise synthesis info (name/message/stack).
127+
auto payload = Object::New(isolate);
128+
Local<Value> nativeExc = GetWrappedJavaThrowable(context, x);
129+
if (!nativeExc.IsEmpty()) {
130+
payload->Set(context, V8StringConstants::GetNativeException(isolate), nativeExc)
131+
.FromMaybe(false);
132+
} else {
133+
string name = "Error";
134+
if (xIsObject) {
135+
Local<Value> nameVal;
136+
if (x.As<Object>()
137+
->Get(context, ArgConverter::ConvertToV8String(isolate, "name"))
138+
.ToLocal(&nameVal) &&
139+
nameVal->IsString()) {
140+
name = ArgConverter::ConvertToString(nameVal.As<String>());
141+
}
142+
}
143+
payload->Set(context, ArgConverter::ConvertToV8String(isolate, "name"),
144+
ArgConverter::ConvertToV8String(isolate, name))
145+
.FromMaybe(false);
146+
payload->Set(context, ArgConverter::ConvertToV8String(isolate, "message"),
147+
ArgConverter::ConvertToV8String(isolate, message))
148+
.FromMaybe(false);
149+
payload->Set(context, V8StringConstants::GetStack(isolate),
150+
ArgConverter::ConvertToV8String(isolate, stack))
151+
.FromMaybe(false);
152+
}
153+
154+
errObj->SetPrivate(context, brand, payload).FromMaybe(false);
155+
156+
info.GetReturnValue().Set(errObj);
157+
}
158+
159+
void Interop::Init(Local<Context> context) {
160+
auto isolate = context->GetIsolate();
161+
auto global = context->Global();
162+
163+
auto interop = Object::New(isolate);
164+
165+
Local<Function> escapeException;
166+
if (!Function::New(context, EscapeExceptionCallback).ToLocal(&escapeException)) {
167+
throw NativeScriptException("Interop::Init: failed to create escapeException");
168+
}
169+
170+
if (!interop->Set(context, ArgConverter::ConvertToV8String(isolate, "escapeException"),
171+
escapeException)
172+
.FromMaybe(false) ||
173+
!global->Set(context, ArgConverter::ConvertToV8String(isolate, "interop"), interop)
174+
.FromMaybe(false)) {
175+
throw NativeScriptException("Interop::Init: failed to install the interop global");
176+
}
177+
}
178+
179+
jthrowable Interop::ExtractEscapedJavaException(JEnv& env,
180+
const Local<Object>& errObj) {
181+
auto isolate = Isolate::GetCurrent();
182+
auto context = isolate->GetCurrentContext();
183+
if (context.IsEmpty()) {
184+
return nullptr;
185+
}
186+
187+
Local<Private> brand = GetBrand(isolate);
188+
Local<Value> payload;
189+
if (!errObj->HasPrivate(context, brand).FromMaybe(false) ||
190+
!errObj->GetPrivate(context, brand).ToLocal(&payload) ||
191+
!payload->IsObject()) {
192+
return nullptr;
193+
}
194+
195+
Local<Value> nativeExc;
196+
if (!payload.As<Object>()
197+
->Get(context, V8StringConstants::GetNativeException(isolate))
198+
.ToLocal(&nativeExc) ||
199+
nativeExc.IsEmpty() || !nativeExc->IsObject()) {
200+
return nullptr;
201+
}
202+
203+
auto objectManager = Runtime::GetObjectManager(isolate);
204+
auto javaObj = objectManager->GetJavaObjectByJsObject(nativeExc.As<Object>());
205+
if (javaObj.IsNull()) {
206+
return nullptr;
207+
}
208+
209+
JniLocalRef objClass(env.GetObjectClass(javaObj));
210+
jclass throwableClass = env.FindClass("java/lang/Throwable");
211+
if (env.IsAssignableFrom(objClass, throwableClass) != JNI_TRUE) {
212+
return nullptr;
213+
}
214+
215+
return static_cast<jthrowable>(env.NewLocalRef(javaObj));
216+
}

0 commit comments

Comments
 (0)