Skip to content

Route Java to JS calls to the runtime that created the binding - #2015

Draft
edusperoni wants to merge 1 commit into
mainfrom
fix/sbg-runtime-identity
Draft

Route Java to JS calls to the runtime that created the binding#2015
edusperoni wants to merge 1 commit into
mainfrom
fix/sbg-runtime-identity

Conversation

@edusperoni

@edusperoni edusperoni commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

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.Runtime therefore has to re-derive the target of every Java→JS call from the calling thread. Today that means, in order:

  1. the currentRuntime thread-local,
  2. the currently-entered V8 isolate (getCurrentRuntimeId()), which yields nothing on a plain Java callback thread (Binder, foreign Handler, executor),
  3. 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 enableMultithreadedJavascript off (the default), strongJavaObjectToID/weakJavaObjectToID are plain HashMap/NativeScriptHashMap. getObjectRuntime reads other runtimes' maps from a foreign thread while their owning threads mutate them on every JS object creation and GC pass.

Failures are undiagnosable. dispatchCallJSMethodNative discarded the result of threadScheduler.post(): a post to a quitting worker looper returned null (or a zeroed primitive) to the caller with no log at all. initInstance dereferenced 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:

  • the static binding generator, in Java source;
  • the runtime binding generator (Dump.java), which dexes the proxies behind new java.lang.Runnable({...}) — 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 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_ID is -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.

Side finding, not fixed here. I first tried making INVALID_RUNTIME_ID 0 and starting runtime ids at 1, which would have removed the seeding entirely. That crashes the app at startup with Cannot find runtime for id:0, because JsV8InspectorClient.cpp:853 does Runtime::GetRuntime(0) — the inspector hard-codes the assumption that the main runtime is id 0. Worth knowing about independently of this PR; it means runtime id 0 is load-bearing and undocumented.

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: already null, now at least logged).

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 — 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.

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 actually 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.

Points I'd like review on

  • One inherited asymmetry. A call whose owner has terminated is dropped, but the pre-existing "no runtime holds this object at all" path still throws 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.
  • Hand-written asmdex in Dump.java is 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 existing equals__super/hashCode__super templates (parameters occupy the last registers), and the whole emulator suite exercises them, since essentially every listener in the specs is one of these proxies.
  • Dex cost: two methods and one int field per generated binding class. Real but modest; flagging it for method-count budgets.
  • Runtime id numbering is unchanged, deliberately — see the side finding above.
  • Native side is unchanged. Java_com_tns_Runtime_callJSMethodNative still returns nullptr silently when TryGetRuntime(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 (ReThrowToJava calls Isolate::GetCurrent(), which is exactly what is unavailable when the runtime is gone), so I left it out rather than guess.
  • A call to an object owned by a terminated worker currently no-ops with a log. The other option is falling back to main, which I avoided: main holds a wrapper but not the worker's JS implementation, so it would run the wrong code rather than none.

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.js asserts the exact interface list of a runtime-generated proxy; updated for the added interface.
  • GeneratorTest gains coverage that a generated binding implements NativeScriptRuntimeBound, defaults to INVALID_RUNTIME_ID, and round-trips the stamp; and that the exception-suppressing variant compiles and emits passSuppressedExceptionToJs(this, ...). That path had no compile coverage before — it needed an android.util.Log test stub, added here.
  • Existing SBG suites pass (19 tests).
  • :runtime:testDebugUnitTest passes.
  • Regenerated the test app's bindings end-to-end and compiled the whole app against them.
  • Full Jasmine suite on an API 35 emulator, re-run after the drop-don't-throw revision: 124 suites, 879 tests, 0 failures, 0 errors, 4 skipped. (runtestsAndVerifyResults itself aborts at deletePreviousResultXml on Play-image AVDs, which refuse adb root, so this was run by installing the debug APK and pulling the results XML via run-as.)

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 834f4c17-b0b8-43e9-84c7-df5ac8b784a3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@edusperoni
edusperoni force-pushed the fix/sbg-runtime-identity branch from c67c43f to 3d5fb2d Compare August 15, 2026 21:15
…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
edusperoni force-pushed the fix/sbg-runtime-identity branch from 3d5fb2d to 53f9373 Compare August 15, 2026 22:45
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.

1 participant