Skip to content

fix(runtime): generate named Java proxies at runtime when not precompiled - #2016

Open
NathanWalker wants to merge 4 commits into
feat/hmr-dev-sessionsfrom
feat/hmr-dev-sessions-javaproxy-handling
Open

fix(runtime): generate named Java proxies at runtime when not precompiled#2016
NathanWalker wants to merge 4 commits into
feat/hmr-dev-sessionsfrom
feat/hmr-dev-sessions-javaproxy-handling

Conversation

@NathanWalker

@NathanWalker NathanWalker commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Named proxies — Base.extend('a.b.C', {...}) and @javaproxy('a.b.C') — only
existed if the static binding generator had compiled them ahead of time:
ClassResolver routed com.tns.gen* names through DexFactory and threw for
everything else. SBG scans only assets/app, so a dev server that keeps
@nativescript/core off disk (Vite HMR serves it over HTTP) lost every named
proxy in core and crashed at boot with
LookedUpClassNotFound: Class "com.tns.FragmentClass" not found.

  • ClassResolver: on LookedUpClassNotFound, fall through to DexFactory and
    generate the proxy dex at runtime — classes and interfaces alike. Logs a
    warning per class so SBG coverage gaps stay visible.
  • DexFactory: tell named proxies apart from anonymous bindings so a dotted
    interface implementation keeps its requested name instead of the derived
    com.tns.gen one. Core's two @JavaProxy proxies implement
    ActivityLifecycleCallbacks and ComponentCallbacks2 — both interfaces.
  • ProxyGenerator: thumb-suffix dotted proxy file names. Unsuffixed files
    never matched getDexFile's probe (regenerated every launch), never
    matched purgeDexesByThumb, and jarFile.exists() reused their stale .jar
    across app versions.
  • ModuleInternal: anchor relative runModule paths to the app root in the ES
    module branch, as the require branch always has. A generated binding's
    @JavaScriptImplementation carries ./bundle.mjs — an app-root-relative
    module name by SBG convention — but the ESM branch stats it as a
    filesystem path from the process cwd (after the registry-key canonicalizer
    folds the ./ away), so it failed as "Cannot find module bundle.mjs".

Manifest-referenced classes (com.tns.NativeScriptActivity) resolve through
this path because the generated dex is injected into the app class loader
during entry evaluation — which the boot backstop holds until settled —
before the framework instantiates the activity. A precompiled class is
still the sturdier answer where a build step can provide one; this makes
the missing-class case survivable instead of fatal.

…iled

Named proxies — Base.extend('a.b.C', {...}) and @javaproxy('a.b.C') — only
existed if the static binding generator had compiled them ahead of time:
ClassResolver routed com.tns.gen* names through DexFactory and threw for
everything else. SBG scans only assets/app, so a dev server that keeps
@nativescript/core off disk (Vite HMR serves it over HTTP) lost every named
proxy in core and crashed at boot with
LookedUpClassNotFound: Class "com.tns.FragmentClass" not found.

- ClassResolver: on LookedUpClassNotFound, fall through to DexFactory and
  generate the proxy dex at runtime — classes and interfaces alike. Logs a
  warning per class so SBG coverage gaps stay visible.
- DexFactory: tell named proxies apart from anonymous bindings so a dotted
  interface implementation keeps its requested name instead of the derived
  com.tns.gen one. Core's two @javaproxy proxies implement
  ActivityLifecycleCallbacks and ComponentCallbacks2 — both interfaces.
- ProxyGenerator: thumb-suffix dotted proxy file names. Unsuffixed files
  never matched getDexFile's probe (regenerated every launch), never
  matched purgeDexesByThumb, and jarFile.exists() reused their stale .jar
  across app versions.
- ModuleInternal: anchor relative runModule paths to the app root in the ES
  module branch, as the require branch always has — a generated binding's
  @JavaScriptImplementation carries a project-relative path (./bundle.mjs)
  that java.io.File flattens, and the ESM branch went straight to the
  filesystem from the process cwd.

Manifest-referenced classes (com.tns.NativeScriptActivity) resolve through
this path because the generated dex is injected into the app class loader
during entry evaluation — which the boot backstop holds until settled —
before the framework instantiates the activity. A precompiled class is
still the sturdier answer where a build step can provide one; this makes
the missing-class case survivable instead of fatal.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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: 00d15446-786b-428f-a99e-9cecae8379d2

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.

NathanWalker and others added 3 commits August 19, 2026 18:14
runModule now sends every entry through Module.resolveEntryPath: absolute
paths and URLs pass through, and a scheme-less relative name - the
@JavaScriptImplementation convention, ./bundle.mjs by SBG's stamp - goes
through the same resolution require uses, extension and directory probing
included, against the app root. A missing entry throws require's
"Failed to find module" from Java instead of a cwd-relative native stat
failure.

This replaces AnchorToAppRoot. Anchoring inside ModuleInternal::Load
resolved the load but not the identity: Runtime::RunModule probes the
boot backstop with the path it was handed ("Java resolves package.json's
main before handing the path over, so the entry the backstop probes is
the very one that was just evaluated"), so an anchored relative entry
evaluated under the app-root key while the backstop probed the unanchored
one - a registry miss that silently disarmed the hold and the rejection
fatal for exactly the dev-served entries this branch exists to support.
Resolving before the JNI crossing keeps one resolution seam and makes the
evaluated entry and the probed entry the same string by construction.
… primary

The dex cache key was name + thumb, and the thumb only changes on
reinstall - so once the thumb suffix made the cache actually hit, an
HMR edit to a proxy's method overrides would silently keep loading the
previous dex. The file name now also carries a digest of everything that
shapes the generated proxy besides its name: base class, interface flag,
and the sorted override and interface lists, so JS enumeration order
cannot cause a spurious miss. The digest sits after the thumb, so the
thumb-keyed purge keeps matching old generations.

When runtime generation itself fails, ClassResolver now rethrows the
original LookedUpClassNotFound with the generation failure attached as a
suppressed exception, instead of letting a downstream ClassNotFound or
ASM error replace the one message that names the class the app actually
asked for.
@edusperoni

Copy link
Copy Markdown
Collaborator

Reviewed this in depth alongside the loader work — the runtime-generation approach is sound, the !isNamedProxy guards are provably inert for every pre-existing path (anonymous bindings byte-identical), and the dotted-interface naming pipeline checks out end to end, nested $ interfaces included. Two commits landed on this branch from that review, plus one discovery worth writing down.

The thumb fix is bigger than the commit message says. desiredDexClassName for a plain anonymous class extend (android.app.Activity_MyActivity_59_56_) contains dots, so since 2018 every anonymous class extend has taken the dotted, un-thumbed filename branch: the getDexFile probe (…-<thumb>.dex) never matched, the dex regenerated every launch, and the freshly generated bytes were then discarded because the un-thumbed .jar from launch #1 already existed and was loaded instead — including across CLI livesync pushes, where the sentinel purge also never matched these files. The on-disk dex cache has only ever worked for anonymous interface implementations. This one-line suffix turns it on for class extends for the first time — a real cold-start win for extend-heavy apps. (One overstatement in my earlier analysis, corrected: stale jars did not survive store updates — dexDir is under code_cache, which the platform wipes on upgrade. The within-install reuse was the operative bug.)

Commit d3bbd46b — entry resolution moved to Java, replacing AnchorToAppRoot. The anchor resolved the load but not the identity: Runtime::RunModule probes the boot backstop with the path it was handed, so an anchored relative entry evaluated under the app-root registry key while the backstop probed the unanchored one — a silent registry miss that disarmed the hold and the rejection fatal for exactly the dev-served entries this branch targets (and this PR's own mechanism depends on the hold: the dex injection must complete before the framework instantiates the activity). Module.resolveEntryPath resolves scheme-less names through the same machinery require uses (extension/index probing included) before the JNI crossing, so the evaluated entry and the probed entry are the same string by construction, and the runtime keeps a single resolution seam.

Commit 8eb0b868 — two hardening fixes on the new path.

  • The cache key was name + thumb, and the thumb only changes on reinstall — so with the cache now live, an HMR edit to a named proxy's method overrides would keep loading the previous dex (named proxies get no file_line_col uniqueness). The filename now also carries a digest of the proxy's contents (base class, interface flag, sorted overrides/interfaces), placed after the thumb so the thumb-keyed purge still matches.
  • When runtime generation itself fails, ClassResolver now rethrows the original LookedUpClassNotFound with the generation failure attached as suppressed, instead of letting a downstream ClassNotFoundException/ASM error replace the one message naming the class the app asked for. (The previous if (clazz == null) throw notFound was unreachable — DexFactory.resolveClass throws rather than returning null.)

Both verified with the full suite on an API 33 emulator (1031/0).

Deferred items from the review — pre-existing defects this PR makes routine rather than theoretical (concurrent generation corrupting dexes via Dump's static StringBuffer, the jar exists()/setReadOnly() race, an unchecked short read, plus smaller naming nits) — are filed as #2019 to pick up after the ESM work lands.

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