Route Java to JS calls to the runtime that created the binding - #2015
Draft
edusperoni wants to merge 1 commit into
Draft
Route Java to JS calls to the runtime that created the binding#2015edusperoni wants to merge 1 commit into
edusperoni wants to merge 1 commit into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
edusperoni
force-pushed
the
fix/sbg-runtime-identity
branch
from
August 15, 2026 21:15
c67c43f to
3d5fb2d
Compare
…tance
Binding classes carried no runtime identity, so com.tns.Runtime had to re-derive
the target of every Java->JS call from the calling thread: the currentRuntime
thread-local, then the entered V8 isolate, then a linear scan of every live
runtime's object maps (getObjectRuntime). That is wrong in two ways and racy in
a third.
Ownership was inferred from "some runtime has a wrapper for this object", but a
wrapper only records that the instance crossed into that isolate. An instance
created by one runtime and later passed into another exists in both maps, so a
call could dispatch into an isolate that holds a wrapper but not the user's JS
implementation. The scan also reads other runtimes' strongJavaObjectToID /
weakJavaObjectToID from a foreign thread; with enableMultithreadedJavascript
off (the default) those are plain HashMap/NativeScriptHashMap being mutated
concurrently by their owning threads.
Both generators now emit com.tns.NativeScriptRuntimeBound: the static binding
generator in Java source, and the runtime binding generator - which dexes the
proxies behind new java.lang.Runnable({...}) and is the more common of the two -
in asmdex alongside the __initialized field and equals__super it already emits.
initInstance stamps the id once; later registrations by other runtimes
(getOrCreateJavaObjectID, when the instance crosses into another isolate) must
not overwrite it, because only the creating runtime holds the JS implementation
behind the generated overrides.
INVALID_RUNTIME_ID is -1, so both generators seed the field rather than rely on
its zero default: 0 is the main runtime's id, and JsV8InspectorClient hard-codes
it as such. The dex proxies seed it as early as a constructor may touch the
instance - the verifier rejects field access on an uninitialized reference, so
not before the superclass constructor. A proxied method invoked from within that
superclass constructor therefore still reads 0 and resolves to the main runtime,
which finds no object id registered for the instance and reports that; before
this change the same call reported that no runtime held the instance.
A call with nowhere left to run is dropped and logged rather than executed
against an unrelated runtime. These arrive on Android callbacks - lifecycle,
listeners, draw - so throwing would take down the process because a worker
ended; the loss stays contained to the one call. Primitive returns reuse the
existing default-value substitution so the generated cast can still unbox.
- callJSMethod against a terminated owner drops instead of scanning and
landing on whichever runtime happens to hold a wrapper.
- dispatchCallJSMethodNative ignored the result of threadScheduler.post(); a
post to a quitting worker looper already returned null silently, and now
says so in the log.
initInstance is the one case that still throws: it dereferenced a null runtime,
so constructing a binding off a runtime thread produced a bare NPE. Dropping
there would leave an unregistered instance whose every later call fails anyway,
further from the cause, so it reports instead.
passSuppressedExceptionToJs routed by calling thread, so an exception suppressed
on behalf of another runtime was reported to the wrong one - and the message it
built was never passed to passDiscardedExceptionToJs. It now takes the instance,
resolves the same owner, and reports on that runtime's own thread without
blocking. The two-argument overload stays for already-generated bindings.
Bindings from an older generator carry no id and keep the previous thread-based
resolution. Costs two methods and one int field per binding class; noted for dex
budgets.
edusperoni
force-pushed
the
fix/sbg-runtime-identity
branch
from
August 15, 2026 22:45
3d5fb2d to
53f9373
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Draft — opening this for design discussion before polishing.
SBG generates real Java classes for JS code, but those classes carry no record of which runtime is using them.
com.tns.Runtimetherefore has to re-derive the target of every Java→JS call from the calling thread. Today that means, in order:currentRuntimethread-local,getCurrentRuntimeId()), which yields nothing on a plain Java callback thread (Binder, foreignHandler, executor),getObjectRuntime()— a linear scan of every live runtime, probing each one's own object→id map until one contains the instance.Three problems fall out of that.
Ownership is inferred from the wrong signal. The routing check is "does this runtime have an id for the object". But a wrapper only records that the instance crossed into that isolate, not that the isolate implements it. An instance created by one runtime and later passed into another exists in both maps, so a call can dispatch into an isolate that holds a wrapper but not the user's JS implementation.
The owner scan is a data race. With
enableMultithreadedJavascriptoff (the default),strongJavaObjectToID/weakJavaObjectToIDare plainHashMap/NativeScriptHashMap.getObjectRuntimereads other runtimes' maps from a foreign thread while their owning threads mutate them on every JS object creation and GC pass.Failures are undiagnosable.
dispatchCallJSMethodNativediscarded the result ofthreadScheduler.post(): a post to a quitting worker looper returnednull(or a zeroed primitive) to the caller with no log at all.initInstancedereferenced a possibly-null runtime, so constructing a binding off a runtime thread produced a bare NPE.What this changes
Both generators now emit
com.tns.NativeScriptRuntimeBound, so every binding carries the id of the runtime that registered it:Dump.java), which dexes the proxies behindnew java.lang.Runnable({...})— the more common of the two — in asmdex, alongside the__initializedfield andequals__superit already emits.initInstancestamps the id once. Later registrations by other runtimes (getOrCreateJavaObjectID, when the instance crosses isolates) must not overwrite it, since only the creating runtime holds the JS implementation behind the generated overrides. Runtime ids are monotonic and never reused, so a stale id can never collide with a newer runtime.INVALID_RUNTIME_IDis-1, so both generators seed the field rather than rely on its zero default — 0 is a real runtime id (the main runtime's). The dex proxies seed it as early as a constructor may touch the instance; it can't be earlier, since the verifier rejects field access on an uninitialized reference. A proxied method invoked from within a superclass constructor therefore still reads 0, resolves to the main runtime, finds no object id registered for the instance and reports that — where before the change the same call reported that no runtime held the instance. Both are errors; only the wording moves.Bindings built by an older generator carry no id and keep the previous thread-based resolution, unchanged.
A call with nowhere left to run is dropped and logged, not thrown. These arrive on Android callbacks — lifecycle, listeners, draw — so throwing would take down the process because a worker ended; the loss stays contained to the one call. Primitive returns reuse the existing default-value substitution so the generated cast can still unbox. That covers a call against a terminated owner (previously: scan and dispatch to whichever runtime happened to hold a wrapper) and a failed
post()to a quitting looper (previously: alreadynull, now at least logged).initInstanceis the one case that still throws. It dereferenced a null runtime, so constructing a binding off a runtime thread produced a bare NPE — already a crash, so this isn't a new one. Dropping there would leave an unregistered instance whose every later call fails anyway, further from the cause.passSuppressedExceptionToJsrouted by calling thread, so an exception suppressed on behalf of another runtime was reported to the wrong one — and the message it built was never actually passed topassDiscardedExceptionToJs. It now takes the instance, resolves the same owner, and reports on that runtime's own thread without blocking. The two-argument overload stays for already-generated bindings.Points I'd like review on
Cannot find runtime for instance=...— untouched here, since it's long-standing behavior and a different condition (never a live binding, versus a lifecycle race a worker ending can legitimately cause). Happy to make it drop too if you'd rather it be uniform.Dump.javais the part most worth a careful read — mistakes there surface as dex verification failures at runtime, not compile errors. The two accessors mirror the register conventions of the existingequals__super/hashCode__supertemplates (parameters occupy the last registers), and the whole emulator suite exercises them, since essentially every listener in the specs is one of these proxies.Java_com_tns_Runtime_callJSMethodNativestill returnsnullptrsilently whenTryGetRuntime(runtimeId)misses. The Java guard now covers the realistic window; what remains is a narrow TOCTOU race between the Java check and the JNI call. Throwing from there needs care (ReThrowToJavacallsIsolate::GetCurrent(), which is exactly what is unavailable when the runtime is gone), so I left it out rather than guess.Related Pull Requests
None yet — iOS has the same static-binding shape and would want a matching change if this direction is accepted.
Does your pull request have unit tests?
Yes, plus manual verification:
testsForRuntimeBindingGenerator.jsasserts the exact interface list of a runtime-generated proxy; updated for the added interface.GeneratorTestgains coverage that a generated binding implementsNativeScriptRuntimeBound, defaults toINVALID_RUNTIME_ID, and round-trips the stamp; and that the exception-suppressing variant compiles and emitspassSuppressedExceptionToJs(this, ...). That path had no compile coverage before — it needed anandroid.util.Logtest stub, added here.:runtime:testDebugUnitTestpasses.runtestsAndVerifyResultsitself aborts atdeletePreviousResultXmlon Play-image AVDs, which refuseadb root, so this was run by installing the debug APK and pulling the results XML viarun-as.)