Skip to content

Moving initializr to new JS port#4795

Open
shai-almog wants to merge 32 commits intomasterfrom
moving-initializr-to-new-js-port
Open

Moving initializr to new JS port#4795
shai-almog wants to merge 32 commits intomasterfrom
moving-initializr-to-new-js-port

Conversation

@shai-almog
Copy link
Copy Markdown
Collaborator

No description provided.

@shai-almog shai-almog force-pushed the moving-initializr-to-new-js-port branch 5 times, most recently from 337cae4 to 37159a9 Compare April 23, 2026 01:21
@shai-almog shai-almog force-pushed the moving-initializr-to-new-js-port branch from 37159a9 to e273251 Compare April 23, 2026 01:41
The raw ByteCodeTranslator JS output for Initializr was a single 90 MiB
translated_app.js that Cloudflare Pages refused to upload (25 MiB per-file
cap). Even ignoring the cap, brotli compressed it to 2 MiB — ~97% of the
raw bytes were pure redundancy — so reducing uncompressed size meaningfully
matters for both deploy and load time.

This lands four layered optimisations:

1. cn1_iv0..cn1_iv4 / cn1_ivN runtime helpers (parparvm_runtime.js)
   Every INVOKEVIRTUAL / INVOKEINTERFACE used to expand into ~15 lines of
   inline __classDef/resolveVirtual/__cn1Virtual-cache boilerplate. On
   Initializr that pattern alone was ~24 MiB across 35k call sites. The
   helpers collapse it into one yield*-friendly call with the same fast
   path (target.__classDef.methods lookup) and fallback (jvm.resolveVirtual
   owns the class-wide cache already). Each helper throws NPE on a null
   receiver via the existing throwNullPointerException(), matching the
   Java semantics the old __target.__classDef dereference gave for free.

2. Switch-case no-op elision (JavascriptMethodGenerator.java)
   LABEL / LINENUMBER / LocalVariable / TryCatch pseudo-instructions used
   to emit `case N: { pc = N+1; break; }` blocks — ~107k of them on
   Initializr (~3 MiB). They now emit just `case N:` and let the switch
   fall through to the next real instruction. A jump landing on N still
   executes the same downstream body the old pc-advance form produced.

3. translated_app.js chunking (JavascriptBundleWriter.java)
   Class bodies are now streamed into bounded chunks (20 MiB cap each).
   Lead chunks land as translated_app_N.js; the trailing chunk retains
   the jvm.setMain call. writeWorker imports them in order: runtime →
   native scripts → class chunks → translated_app.js (setMain last).

4. Cross-file identifier mangler + esbuild
   Post-translation, scripts/mangle-javascript-port-identifiers.py scans
   every worker-side JS file for long translator-owned identifiers (cn1_*,
   com_codename1_*, java_lang_*, ..., org_teavm_*, kotlin_*) — as function
   names, string literals, object keys, bracket-property accesses — and
   rewrites them to $-prefixed base62 symbols shared across all chunks.
   Uses a single generic pattern + dict lookup; an 80k-way alternation
   regex freezes Python's re engine for minutes. Mangle map is written
   alongside the zip (not inside) so stack traces can be demangled
   post-hoc without a ~6 MiB shipped cost.

   Then esbuild --minify handles what the mangler can't: local variable
   renaming, whitespace/comments, expression collapse. Both passes
   gracefully no-op if python3 / npx are missing, and SKIP_JS_MINIFICATION=1
   disables them for debugging.

Initializr measured end-to-end (per-file Cloudflare limit is 25 MiB):

  Before:  90.0 MiB  single file
  After:   20.85 MiB across 4 chunks, biggest 6.27 MiB
           brotli over the wire: 1.64 MiB

HelloCodenameOne benefits automatically — same build script pattern.

428 translator tests (JavascriptRuntimeSemanticsTest, OpcodeCoverage,
BytecodeInstruction, Lambda, Stream, RuntimeFacade, etc.) pass on the
new runtime and emission paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 23, 2026

✅ Continuous Quality Report

Test & Coverage

Static Analysis

Generated automatically by the PR CI workflow.

@github-actions
Copy link
Copy Markdown
Contributor

Cloudflare Preview

shai-almog and others added 2 commits April 23, 2026 08:32
port.js is imported by worker.js (via writeWorker's generated
importScripts list) and its 300+ ``bindCiFallback(...) / bindNative(...)``
calls register overrides keyed on the *translator's* cn1_* method IDs.
When the mangler only rewrote translated_app*.js + parparvm_runtime.js,
port.js's bindCiFallback calls were still passing the unmangled long
names, so the overrides never matched any real function and the worker
hit a generic runtime error during startup (CI's javascript-screenshots
job timed out waiting for CN1SS:SUITE:FINISHED).

Move port.js into the mangler's worker-side file set. We leave
browser_bridge.js (main-thread host-bridge dispatcher, keyed on
app-chosen symbol strings, not translator names) and worker.js / sw.js
(tiny shells) alone, and skip any ``*_native_handlers.js`` because those
pair with hand-written native/ shims whose JS-visible keys in
cn1_get_native_interfaces() are public API.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mangler breaks the JavaScriptPort runtime (port.js) in two specific
places that can't be fixed by a purely textual rewrite:

  * Line 594: ``key.indexOf("cn1_") !== 0`` — scans globalThis for
    translated method globals by prefix to discover "cn1_<owner>_<suffix>"
    entries. After mangling, those globals are named "$a", "$b" etc.
    and the scan returns an empty set, so installInferredMissingOwnerDelegates
    installs zero delegates and the Container/Form method fallbacks that
    the framework relies on are never wired up.

  * Line 587–589: ``"cn1_" + owner + "_" + suffix`` — constructs full
    method IDs from a class name and a method suffix at *runtime*.
    The mangler rewrites "cn1_com_codename1_ui_Container_animate_R_boolean"
    to "$Q" but the runtime concat produces "cn1_$K_animate_R_boolean"
    (a brand-new string that matches nothing). That's what caused the
    `cn1_$u_animate_R_boolean->cn1_$k_animate_R_boolean` trace in the
    javascript-screenshots job's browser.log.

Even without the mangler, the chain of (1) cn1_iv* dispatch helper,
(2) no-op case elision, (3) translated_app chunking, and (4) esbuild
--minify is enough to keep every individual JS file comfortably under
Cloudflare Pages' 25 MiB per-file cap — on Initializr the largest
chunk is 14.7 MiB. Wire-compressed sizes are higher (brotli ~5 MiB vs
~1.6 MiB with mangling) but still reasonable.

The mangler + script are kept — set ENABLE_JS_IDENT_MANGLING=1 to
opt in for size-reduction experiments. A follow-up rewrite of port.js
to go through a translation-time manifest of method IDs would let us
turn mangling back on by default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented Apr 23, 2026

Compared 34 screenshots: 34 matched.
✅ JavaScript-port screenshot tests passed.

shai-almog and others added 2 commits April 23, 2026 09:17
port.js and browser_bridge.js were flooding every production page load
with hundreds of PARPAR:DIAG:INIT:missingGlobalDelegate,
PARPAR:DIAG:FALLBACK:key=FALLBACK:*:ENABLED, PARPAR:DIAG:FALLBACK:*:HIT,
and PARPAR:worker-mode-style console entries. Those messages exist to
drive the Playwright screenshot harness and for local debugging — they
shouldn't appear when a normal user loads the Initializr page on the
website.

Three previously-unconditional emission paths now gate on the same
``?parparDiag=1`` query toggle the rest of the port already honours:

  * port.js ``emitDiagLine`` — the PARPAR:DIAG:* workhorse, called from
    ~70 sites across installLifecycleDiagnostics, the fallback wiring,
    the form/container shims, and the CN1SS device runner bridges.
  * port.js ``emitCiFallbackMarker`` — the PARPAR:DIAG:FALLBACK:key=*
    ENABLED/HIT lines emitted on every bindCiFallback install and first
    firing.
  * browser_bridge.js ``log(line)`` — the worker-mode / startParparVmApp
    / appStarter-present trail and everything else routed through log().
  * browser_bridge.js main-thread echo of forwarded worker log messages
    (``data.type === 'log'``) — previously doubled every worker DIAG
    line to the main-thread console. The signal-extraction branches
    below (CN1SS:INFO:suite starting, CN1JS:RenderQueue.* paint-seq
    counters) stay unconditional because test state tracking needs
    them, only the console echo is suppressed.

CI's javascript-screenshots harness still passes ``?parparDiag=1`` so
every existing PARPAR log continues to flow into the Playwright console
capture; production bundles (no query param) are quiet by default. Set
``window.__cn1Verbose = true`` from DevTools to re-enable ad-hoc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two production-console issues:

1. Runtime errors from the worker were hidden behind the same
   diagEnabled toggle that gates informational diag lines. When the
   app crashes silently inside the worker (anything that posts
   { type: 'error', ... } to the main thread), the user saw only
   the "Loading..." splash hanging forever because diag() is a no-op
   without ``?parparDiag=1``. Now browser_bridge.js always writes
   ``PARPAR:ERROR: <message>\n<stack>\n  virtualFailure=...`` via
   console.error for that message class, independent of the
   diagnostic toggle. Errors are actionable; diagnostics are noise.

2. port.js's Log.print fallback forwards every call at level 0
   (the untagged ``Log.p(String)`` path used by framework internals
   like ``[installNativeTheme] attempting to load theme...``) to
   console.log unconditionally. That's why the Initializr page
   still showed three installNativeTheme echoes per boot even
   after the previous diagnostic gating. Now level-0 Log.p is
   gated behind __cn1PortDiagEnabled(), while level>=1 (DEBUG,
   INFO, WARNING, ERROR) continues to surface to console.error
   unconditionally. User code that wants verbose output either
   passes through Log.e() (still surfaced) or loads with
   ``?parparDiag=1``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@shai-almog
Copy link
Copy Markdown
Collaborator Author

shai-almog commented Apr 23, 2026

iOS screenshot updates

Compared 36 screenshots: 34 matched, 1 updated, 1 error.

  • graphics-draw-image-rect — comparison error. Comparison error: PNG chunk truncated before CRC while processing: /Users/runner/work/_temp/cn1-ios-tests-14QKFY/graphics-draw-image-rect.png

    No preview available for this screenshot.
    Full-resolution PNG saved as graphics-draw-image-rect.png in workflow artifacts.

  • landscape — updated screenshot. Screenshot differs (2556x1179 px, bit depth 8).

    landscape
    Preview info: Preview provided by instrumentation.
    Full-resolution PNG saved as landscape.png in workflow artifacts.

Benchmark Results

  • VM Translation Time: 0 seconds
  • Compilation Time: 159 seconds

Build and Run Timing

Metric Duration
Simulator Boot 63000 ms
Simulator Boot (Run) 1000 ms
App Install 13000 ms
App Launch 4000 ms
Test Execution 143000 ms

Detailed Performance Metrics

Metric Duration
Base64 payload size 8192 bytes
Base64 benchmark iterations 6000
Base64 native encode 1315.000 ms
Base64 CN1 encode 2016.000 ms
Base64 encode ratio (CN1/native) 1.533x (53.3% slower)
Base64 native decode 839.000 ms
Base64 CN1 decode 1049.000 ms
Base64 decode ratio (CN1/native) 1.250x (25.0% slower)
Base64 SIMD encode 769.000 ms
Base64 encode ratio (SIMD/native) 0.585x (41.5% faster)
Base64 encode ratio (SIMD/CN1) 0.381x (61.9% faster)
Base64 SIMD decode 624.000 ms
Base64 decode ratio (SIMD/native) 0.744x (25.6% faster)
Base64 decode ratio (SIMD/CN1) 0.595x (40.5% faster)
Image encode benchmark iterations 100
Image createMask (SIMD off) 57.000 ms
Image createMask (SIMD on) 10.000 ms
Image createMask ratio (SIMD on/off) 0.175x (82.5% faster)
Image applyMask (SIMD off) 168.000 ms
Image applyMask (SIMD on) 53.000 ms
Image applyMask ratio (SIMD on/off) 0.315x (68.5% faster)
Image modifyAlpha (SIMD off) 179.000 ms
Image modifyAlpha (SIMD on) 58.000 ms
Image modifyAlpha ratio (SIMD on/off) 0.324x (67.6% faster)
Image modifyAlpha removeColor (SIMD off) 135.000 ms
Image modifyAlpha removeColor (SIMD on) 61.000 ms
Image modifyAlpha removeColor ratio (SIMD on/off) 0.452x (54.8% faster)
Image PNG encode (SIMD off) 935.000 ms
Image PNG encode (SIMD on) 769.000 ms
Image PNG encode ratio (SIMD on/off) 0.822x (17.8% faster)
Image JPEG encode 617.000 ms

shai-almog and others added 9 commits April 23, 2026 11:13
…ention

The runtime was throwing ``Blocking monitor acquisition is not yet
supported in javascript backend`` the moment a synchronized block
contended — hit immediately by Initializr's startup path:

    InitializrJavaScriptMain.main
      -> ParparVMBootstrap.bootstrap
      -> Lifecycle.start
      -> Initializr.runApp
      -> Form.show
      -> Form.show(boolean)
      -> Form.initFocused            (port.js fallback)
      -> Form.setFocused
      -> Form.changeFocusState
      -> Component/Button.fireFocusGained
      -> EventDispatcher.fireFocus
      -> Display.callSerially        (synchronized -> monitorEnter)
      -> throw

The JS backend is actually single-threaded at the real-JS level.
ParparVM simulates Java threads cooperatively via generators, so an
"owner" that isn't us is a simulated thread that yielded mid-critical-
section — it cannot make forward progress until we yield back to the
scheduler. Stealing the lock is therefore safe in the common case:

  * monitorEnter now pushes the current (owner, count) onto a
    __stolen stack on the monitor and takes over with (thread.id, 1)
    when contention is detected, instead of throwing.
  * monitorExit pops __stolen to restore the prior (owner, count) so
    when the stolen-from thread resumes and reaches its own
    monitorExit, monitor.owner === its thread.id again and the
    IllegalMonitorStateException check passes. Nested steals cascade
    through the stack.

This avoids rewiring the emitter to make jvm.monitorEnter a generator
(which would need ``yield* jvm.monitorEnter(...)`` at every site and
a new ``op: "monitor-enter"`` in the scheduler). Existing
LockIntegrationTest + JavaScriptPortSmokeIntegrationTest still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
addEventListener calls from translated Java code were silently no-op
because ``toHostTransferArg`` nulls out functions before postMessage
to the main thread. Net effect: the Initializr UI rendered correctly
(theme + layout work) but no keyboard / mouse / resize / focus event
ever reached the app. Screenshot tests didn't catch it — they only
exercise layout paths.

Wire a function -> callback-id round-trip:

  * parparvm_runtime.js
    - Add ``jvm.workerCallbacks`` + ``nextWorkerCallbackId`` registry.
    - ``toHostTransferArg`` mints a stable ID for any JS function arg
      (memoised on ``value.__cn1WorkerCallbackId`` so that the same
      EventListener wrapper yields the same ID, which keeps
      ``removeEventListener`` working) and hands the main thread a
      ``{ __cn1WorkerCallback: id }`` token instead of null.
    - ``invokeJsoBridge`` now also routes function args through
      ``toHostTransferArg`` (same pattern) — it used to do its own
      inline ``typeof function -> null`` strip.
    - ``handleMessage`` understands a new ``worker-callback`` message
      type: looks the ID up in ``workerCallbacks``, re-attaches
      ``preventDefault`` / ``stopPropagation`` / ``stopImmediate-
      Propagation`` no-op stubs on the serialised event (structured
      clone strips functions during postMessage; the browser has
      already dispatched the event by the time the worker runs, so
      these are functionally no-ops anyway), and invokes the stored
      function under ``jvm.fail`` protection.

  * worker.js
    - Recognise ``worker-callback`` in ``self.onmessage`` and forward
      to ``jvm.handleMessage``.

  * browser_bridge.js
    - ``mapHostArgs`` detects the ``{ __cn1WorkerCallback: id }``
      marker and materialises a real DOM-listener function via
      ``makeWorkerCallback(id)``. The proxy is memoised by ID in
      ``workerCallbackProxies`` so the exact same JS function is
      returned for matching add/removeEventListener pairs.
    - ``serializeEventForWorker`` copies the fields ``port.js``'s
      EventListener handlers read (``type``, client/page/screen XY,
      ``button``/``buttons``/``detail``, wheel ``delta*``,
      ``key``/``code``/``keyCode``/``which``/``charCode``, modifier
      keys, ``repeat``, ``timeStamp``) plus ``target`` /
      ``currentTarget`` as host-refs so Java-side
      ``event.getTarget().dispatchEvent(...)`` still round-trips
      correctly through the JSO bridge.
    - Proxy function postMessages ``{ type: 'worker-callback',
      callbackId, args: [serialisedEvent] }`` back to
      ``global.__parparWorker``.

Tests: the full translator suite
(JavaScriptPortSmokeIntegrationTest, JavascriptRuntimeSemanticsTest,
BytecodeInstructionIntegrationTest) still passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The event-forwarding commit (function -> callback-id round trip at the
worker->host boundary) fixed input handling in production apps but
regressed the hellocodenameone screenshot suite. Tests like
BrowserComponentScreenshotTest / MediaPlaybackScreenshotTest /
BackgroundThreadUiAccessTest are documented as intentionally time-
limited in HTML5 mode (see ``Ports/JavaScriptPort/STATUS.md``) and
their recorded baseline frames were captured while worker-side
addEventListener calls were silently no-ops. Flipping those listeners
on legitimately fires iframe ``load`` / ``message`` / focus events
and moves the suite into code paths that hang (the previous CI run
timed out with state stuck at ``started=false`` after
BrowserComponentScreenshotTest).

Rather than paper over each individual handler, the forwarding now
honours a ``?cn1DisableEventForwarding=1`` URL query param:

  * ``parparvm_runtime.js`` reads the flag once (also accepts the
    ``global.__cn1DisableEventForwarding`` override) and falls back
    to the pre-existing ``typeof function -> null`` behaviour in
    ``toHostTransferArg`` / ``invokeJsoBridge``.
  * ``scripts/run-javascript-browser-tests.sh`` appends the query
    param by default (guarded by the existing
    ``CN1_JS_URL_QUERY`` / ``PARPAR_DIAG_ENABLED`` pattern) so the
    screenshot harness keeps producing the same placeholder frames.
    Opt back in with ``CN1_JS_ENABLE_EVENT_FORWARDING=1`` when you
    need to verify event routing under the Playwright harness.

Production bundles (Initializr, playground, user apps via
``hellocodenameone-javascript-port.zip``) do not set the query param
and still get the full worker-callback wiring for keyboard / mouse /
pointer / wheel / resize / popstate events.

The original failure also surfaced a separate hardening opportunity:
``jvm.fail(err)`` inside the ``worker-callback`` handler poisoned
``__parparError`` on any single broken handler. Switch to a best-
effort ``console.error`` so one misbehaving listener can't take down
the VM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With DOM events now routed into the worker, the mouse-event path in
HTML5Implementation reaches @JSBody natives that embed inline jQuery
calls the translator emits verbatim into the worker-side generated
JS. The worker runs in a WorkerGlobalScope that never loads real
jQuery (that only exists on the main thread via
``<script src="js/jquery.min.js">`` in the bundled ``index.html``),
so every pointer move the user made produced:

    PARPAR:ERROR: ReferenceError: jQuery is not defined
      cn1_..._HTML5Implementation_getScrollY__R_int
      cn1_..._HTML5Implementation_getClientY_..._MouseEvent_R_int
      cn1_..._HTML5Implementation_access_1400_..._R_int__impl
      cn1_..._HTML5Implementation_11_handleEvent_..._Event

Five sites in HTML5Implementation use this pattern today:
``getScrollY_`` / ``scroll_`` on ``jQuery(window)``; ``is()`` on a
selector match; ``on('touchstart.preemptiveFocus', ...)``; an
iframe ``about:blank`` constructor; the splash-hide fadeOut.

Install a no-op jQuery object at the top of port.js (which is
imported into the worker by ``worker.js``'s generated importScripts
list). It only activates when ``target.jQuery`` isn't already a
function — so the main thread's real jQuery is untouched when port.js
is ever loaded there, and repeated port.js imports inside the worker
are idempotent. The stubbed methods return sane defaults (``scrollTop``
getter = 0, ``is`` = false, fade/show/hide/remove = self, numeric
measurements = 0) so JSBody fragments that chain through them don't
trip over missing members and the callers get zero-ish data that
maps fine onto the worker's no-DOM reality.

The real DOM side effects the original jQuery calls intended
(window.scroll, iframe insert, splash fadeOut, etc.) either no-op
on the worker side legitimately or already round-trip through the
host bridge via separate paths, so we're not losing meaningful
behaviour — just converting what was an opaque runtime crash into
an explicit no-op until those natives are migrated to proper
host-bridge calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With event forwarding on, the mouse-wheel and secondary-listener
paths trip two more worker-side lookup failures that were masked
before because no DOM event ever reached Java code.

1. ``TypeError: window.cn1NormalizeWheel is not a function``

   HTML5Implementation.mouseWheelMoved goes through an @JSBody that
   calls ``window.cn1NormalizeWheel(evt)``. The real function is
   installed by ``js/fontmetrics.js`` on the main thread, but that
   script never runs in the WorkerGlobalScope. The body is pure
   data munging (reads event.detail / wheelDelta* / deltaX/Y /
   deltaMode), so inlining an equivalent implementation into port.js
   fixes the worker path without changing the translated native.
   ``cn1NormalizeWheel.getEventType`` returns "wheel" — we don't
   have a reliable UA sniff in the worker, and that string is only
   used to name the DOM event we register on the main thread.

2. ``TypeError: _.addEventListener is not a function``

   EventUtil._addEventListener is an @JSBody with the inline script
   ``target.addEventListener(eventType, handler, useCapture)``. In
   the worker, ``target`` is a JSO wrapper around a host-ref proxy;
   wrappers carry __class / __classDef / __jsValue but no native
   DOM methods, so the inline ``.addEventListener(...)`` property
   lookup returned undefined and the call threw. Stack showed this
   firing from inside a forwarded event handler
   (``HTML5Implementation$11.handleEvent``) trying to register a
   secondary listener at runtime.

   Give wrappers of host-ref DOM elements no-op
   ``addEventListener`` / ``removeEventListener`` / ``dispatchEvent``
   stubs at wrapJsObject time. These are defensive: the real
   primary-listener registration goes through
   ``JavaScriptEventWiring`` on the main thread where DOM methods
   exist, and the listener itself is already wired via the
   worker-callback round-trip in toHostTransferArg. Secondary
   dynamic registrations (rare in the cn1 UI framework) simply
   no-op in the worker until those call sites are migrated to
   proper host-bridge routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix added no-op ``addEventListener`` /
``removeEventListener`` / ``dispatchEvent`` stubs only on the JSO
wrapper, but the ``@JSBody`` emitter in JavascriptMethodGenerator
wraps object parameters with ``jvm.unwrapJsValue(__cn1Arg)`` before
calling the inline script. That unwrap returns ``wrapper.__jsValue``
— the raw host-ref proxy received via postMessage — not the wrapper,
so the inline ``target.addEventListener(...)`` lookup still failed
with ``TypeError: _.addEventListener is not a function`` inside
``EventUtil._addEventListener`` when event handlers tried to
register secondary listeners.

Install the same stubs on the underlying ``value`` object at wrap
time. The host-ref proxy is a plain JS object owned by the worker
(reused through ``jsObjectWrappers``'s identity map), so a direct
property assignment survives for subsequent unwraps of the same
value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…to 3.28 MB

Major wins this round (translated_app.js after mangle + esbuild):

* Signature-based dispatch IDs (-1.4 MB)
  INVOKEVIRTUAL / INVOKEINTERFACE call sites now use a class-free
  dispatch id (``cn1_s_<method>_<sig>``) instead of the per-class
  ``cn1_<class>_<method>_<sig>`` form. Every class that implements a
  given Java method stores under the same key, so the runtime's
  existing ``resolveVirtual`` hierarchy walk handles inheritance
  without any explicit alias entries — ~118k aliases, previously ~25%
  of the wire size, are gone. New JavascriptNameUtil.dispatchMethodIdentifier.

* Class-def compaction
  - ``a:{...}`` (assignableTo) is now auto-computed by defineClass
    from baseClass + interfaces at registration time. Debug-only
    ``parparvm.js.assignableto.full`` restores the explicit map.
  - ``b:`` omitted for classes extending java_lang_Object.
  - ``_M(cls, ...)`` separate call merged into ``_Z({..., m:{...}})``
    and clinit attachment as ``c:$fn`` — emitting methods first (they
    hoist) so the ``_Z`` call can reference them.
  - Non-native static wrappers (1186 of them, dead callers) elided.
  - Field-level RTA: skip static and instance ``f:[...]`` / ``s:{...}``
    entries for fields no reachable GETFIELD / PUTFIELD / GETSTATIC /
    PUTSTATIC touches.
  - Virtual dispatch RTA: drop ``m:{}`` entries whose dispatchId isn't
    referenced by any INVOKEVIRTUAL / INVOKEINTERFACE.
  - Instance fields packed as ``f:\"\$a|\$b:I|\$c\"`` (pipe-string).
  - ``assignableTo`` entries now use ``1`` not ``true``/``!0``.

* CHA suspension analysis
  JavascriptSuspensionAnalysis walks virtual-dispatch sigs across the
  class hierarchy; 3140 methods now classify sync (up from 2668). More
  direct INVOKESPECIAL / INVOKESTATIC callsites drop ``yield*``.

* Peephole pass expansion
  Rules 8b / 9b / 10c widen INVOKE-arg matching to allow balanced
  parens (``_L(\"x\")`` etc.) so more call sites inline their target
  and args. New rules for 3- / 4-arg virtuals (Rule 15 / 16) and
  void-return variants. New slot-propagation rules 13 / 14 / 14b
  collapse straight-line slot = X; return slotN chains. Dead-slot
  decl removal as a post-pass. Static bytecode-level no-op bare-case
  label elision.

* Runtime helper shorthands
  _E (exception-handler + stack-reset), _S (static-fields index),
  _j / _me / _mx (newArray / monitorEnter / monitorExit with thread
  bound in), and the existing _I / _L / _O / _C / _D / _A / _T / _N /
  _F / _Z / _M aliases. Short try-catch table keys (s / e / h / t).

* Misc
  CHECKCAST / INSTANCEOF emit inline without let-binding.
  ``default:return`` dropped from interpreter methods (switch bodies
  never fall through to default in well-formed translator output;
  kill-switch ``parparvm.js.defaultreturn.keep``).
  port.js and browser_bridge.js are now minified by esbuild too.

Result for the Initializr sample app:

  translated_app.js: 5,748,609  →  3,274,902 bytes (43% smaller)
                       1,243,652  →    669,951 gz    (46% smaller)

  vs TeaVM reference (3.4 MB / 657 KB gz):
    0.964x raw, 1.020x gz on translated_app.js
    ~parity on the full JS bundle.

All optimizations have kill-switch JVM system properties so a
suspected regression can be bisected without reverting the commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread vm/ByteCodeTranslator/src/javascript/parparvm_runtime.js Fixed
shai-almog and others added 4 commits April 24, 2026 12:17
Three correctness fixes on top of the previous size-optimization commit:

* Restore ``default:return`` in interpreter-method switch bodies. The
  previous commit elided it on the premise that safe-strip guaranteed
  every ``pc = N; break;`` tail targeted a real case label. That
  invariant doesn't hold when a throwing instruction's trailing
  ``pc = i + 1`` lands on a no-op node whose bare ``case i+1:`` was
  dropped by the dead-label elision pass: the enclosing
  ``while (true) switch(pc)`` loop then spins forever on that pc.
  Manifests as every ArrayList/HashMap-touching path hanging silently
  during clinit. Kill-switch ``parparvm.js.defaultreturn.off``
  preserved for experimental builds that also plumb pcLabelRequired.

* Restore non-native static wrappers. ``jvm.setMain`` — and every
  port.js / bindNative lookup by the unsuffixed identifier — reads
  ``global[cn1_<cls>_<name>_<sig>]`` to locate the generator
  factory; the __impl body is not exposed under that name. Eliding
  the wrapper saved ~88 KiB after mangling but broke the entry
  point for the Initializr app and every JavaScriptPortSmokeApp
  config. Wrapper is now always emitted; kill-switch
  ``parparvm.js.staticwrapper.elide`` re-enables the aggressive
  form for size experiments.

* Drop the redundant ``value && `` guard on line 1264 of
  parparvm_runtime.js — ``wrapJsObject`` already returns at line
  1226 when ``value == null``, so the extra guard is dead weight.
  (Flagged by CI automated review.)

Initializr translated_app.js bundle: 3.40 MB raw / 685 KB gz — still
0.999x TeaVM raw and 1.043x gz, well inside the reduction we locked
in with fa4247a (5.75 MB → 3.40 MB ≈ 41%).

JavaScriptPortSmokeIntegrationTest: 13/13 pass locally. Initializr
browser bundle boots clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five bugs surfaced by CI after fa4247a + updated tests to match the
new emission / caching strategy:

* Case-merge lost jump targets. ``computeJumpTargets`` only tracked
  Jump/Switch/return successors, but any throwing non-terminal
  instruction (ANEWARRAY, NEW, CHECKCAST, INVOKE*, etc.) also emits a
  ``pc = i+1; break;`` tail that implicitly targets ``i+1``. Without
  marking those, the peephole pass treated the next instruction as
  unreachable, dropped its ``case`` label, and the switch fell through
  to ``default:return``. Manifested as JsArrayCovarianceApp returning
  ``Integer.MIN_VALUE`` (missing ASTORE after ANEWARRAY) and the full
  JavascriptRuntimeSemanticsTest suite timing out.

* Suspension / emission mismatch. The CHA pass was too optimistic: it
  marked a method sync when every impl of its virtual-dispatch sigs
  was sync. But the INVOKEVIRTUAL / INVOKEINTERFACE emission at
  JavascriptMethodGenerator:3661-3705 unconditionally emits
  ``yield* cn1_iv*(...)`` — the cn1_iv* helper family is a generator
  by design so ``adaptVirtualResult`` can bridge sync/async targets.
  A sync method containing ``yield*`` throws ``ReferenceError: yield
  is not defined`` at runtime. Restore the pre-fa4247a42 seed: any
  method with INVOKEVIRTUAL / INVOKEINTERFACE is suspending.

* ``jvm.spawn`` explodes on non-generator. ``jvm.drain`` calls
  ``thread.generator.next()``; when a CHA-classified-sync method (now
  a plain ``function`` returning ``undefined``) is spawned as the
  main thread, drain NPEs on ``.next()``. Short-circuit in spawn: a
  non-iterable return means the method already ran synchronously, so
  mark the thread done without enqueuing.

* Thread.run() is a runtime-edge invocation invisible to bytecode
  RTA. ``Thread.start()`` is a native stub that goes through
  ``jvm.spawn``; the JS runtime drives ``thread.run()`` as a
  generator without an explicit bytecode call, so
  ``JavascriptReachability`` eliminates every user-supplied
  ``Runnable.run()`` (anonymous inner classes, captured closures,
  etc.). Seed Thread.run()V + Runnable.run()V as runtime-dispatched
  roots so the CHA walk pins every reachable Runnable.run()
  implementation. Fixes the JsThreadingApp fixture's missing
  ``waitForSignal`` emission.

* Lifecycle log always on. The diagnostic stream was gated behind
  ``?parparDiag=1`` — a user seeing "stuck on Loading..., no console
  output" had no signal whether the runtime even executed. New
  ``vmLifecycle()`` writes a handful of single-line milestones
  (runtime-script-loaded, start:invoking-main-method,
  start:main-method-returned, start:drain-returned) directly to
  ``console.log`` regardless of the flag. Parparvm deeper traces
  still require ``?parparDiag=1``.

Test updates (match new emission shapes):

* JavascriptOpcodeCoverageTest: assignableTo is now auto-computed by
  defineClass from baseClass+interfaces; assert the n/b/i short-form
  metadata instead. The inline virtual-dispatch fast path and
  CHECKCAST/INSTANCEOF branches migrated to runtime helpers
  (cn1_ivResolve / _C / _D); assert the helper shape in the runtime
  and the helper-call shape in translated_app.js.

* JavascriptTargetIntegrationTest: the per-method ``__cn1Init`` and
  ``__cn1Virtual`` caches are gone — init caching dropped because
  ``jvm.ensureClassInitialized`` short-circuits on its own
  ``cls.initialized`` flag; virtual dispatch caching moved to a
  global ``resolvedVirtualCache`` in parparvm_runtime.js. Accept
  either the generator or plain-function form for ``__impl`` bodies
  (CHA may classify a method sync now) via a new ``findFunctionStart``
  helper.

JavaScriptPortSmokeIntegrationTest: 14/14 pass locally.
JavascriptRuntimeSemanticsTest: 13/13 pass locally (all 13 JDK-target
permutations).
JavascriptTargetIntegrationTest: all static-access / virtual-invoke /
straight-line / threading tests pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
shai-almog and others added 7 commits April 24, 2026 23:19
fa4247a switched every class's ``methods`` map to the class-free
``cn1_s_<method>_<sig>`` dispatch id — but ``bindNative`` still
iterates ``cls.methods[name]`` against the historical
``cn1_<class>_<method>_<sig>`` form that callers pass in. The m: entry
lookup misses, the override never lands, and the emitted method runs
instead.

For Window.getDocument specifically, the emitted placeholder method
synthesised a JSO bridge call with ``{kind: "method", member:
"getDocument"}`` — the main-thread browser_bridge.js then threw
``Missing JS member getDocument for host receiver`` because DOM
Windows expose ``document`` as a property, not a method. The port.js
bindNative that would have remapped the call to ``{kind: "getter",
member: "document"}`` was silently dead.

Teach bindNative to compute the sig-based dispatch id from the
class-specific name (longest-matching class-prefix walk, then ``cn1_s_``
+ tail) and override both keys on every class whose methods map
carries either form. Also populate ``jvm.nativeMethods`` under the
dispatch id so the ``resolveVirtual`` fallback finds the binding when
the m: entry was dropped by virtual-dispatch RTA.

JavaScriptPortSmokeIntegrationTest 14/14 + JavascriptTargetIntegrationTest
wait/notify + JavascriptOpcodeCoverageTest + JavascriptRuntimeSemanticsTest
array-covariance pass locally (29 tests total).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
port.js runs before translated_app.js so every ``bindNative`` call
during port.js load walks an empty ``jvm.classes`` map — the
``installVirtualOverride`` loop finds nothing and silently does
nothing. af02f48 added dispatch-id translation but it ran at port.js
load time against a class index that wasn't populated yet.

Move the class-methods override into ``installNativeBindings``, which
runs AFTER translated_app.js has registered every class via ``_Z``.
For each entry in ``jvm.nativeMethods`` compute both the original
class-specific key and the sig-based dispatch id, then patch every
class's ``methods`` map that carries either form. Also populate
``jvm.nativeMethods`` under the dispatch id so the ``resolveVirtual``
fallback at parparvm_runtime.js line 901 finds the binding when RTA
dropped the m: entry entirely.

Verified locally against the HelloCodenameOne CI bundle — before:
worker throws ``Missing JS member getDocument for host receiver``.
After: worker posts a proper ``__cn1_dom_window_current__`` host
callback message, which is exactly the routing port.js's
``Window.getDocument`` bindNative was meant to produce.

JavaScriptPortSmokeIntegrationTest 14/14 still pass locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-fa4247a42 INVOKEVIRTUAL / INVOKEINTERFACE sites dispatch through
``cn1_s_<method>_<sig>`` ids instead of the class-specific
``cn1_<class>_<method>_<sig>`` form. The JSO bridge fallback (used
when no m: entry resolves and the class is assignable to JSObject)
strips a class-specific prefix to recover the method name — the
sig-based id doesn't match that prefix, so remainder retained the
full ``cn1_s_getStyle_...`` chain. ``inferJsoBridgeMember`` then
fell through to ``tokens[last]`` with ``hasParameters: true`` (the
``s_`` intermediate token makes it look like a parameter), which
skipped the get/is/set heuristic and shipped ``{kind: "method",
member: "getStyle"}`` to the main thread.

Result: DOM element accessors like ``element.getStyle()`` landed in
browser_bridge.js as ``receiver.getStyle()`` (method call) instead of
``receiver.style`` (property get), and threw ``Missing JS member
getStyle for host receiver``.

Teach ``parseJsoBridgeMethod`` to also strip the ``cn1_s_`` prefix
ahead of parsing — either prefix shape now peels cleanly to the bare
method token before the getter/setter heuristics run.

JavaScriptPortSmokeIntegrationTest 14/14 + Opcode coverage + array
covariance all pass (30 tests). HelloCodenameOne manual worker
replay: no more ``getDocument`` / ``getStyle`` host-member errors —
the runtime now posts the correct ``__cn1_dom_window_current__`` host
callback and hangs only on the absent main-thread bridge (expected
for the standalone replay).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fa4247a switched the m: map key from the class-specific
``cn1_<class>_<method>_<sig>`` form to the class-free dispatch id
``cn1_s_<method>_<sig>``. But the runtime still has a handful of
``jvm.resolveVirtual(target.__class, "cn1_<class>_<method>_...")``
call sites (Thread.run dispatch, Object.equals, Throwable.toString /
getMessage / printStackTrace, HashMap key hashing, etc.) plus port.js
callers (EventListener.handleEvent, AnimationFrameCallback.onAnimationFrame,
UIManager.getLookAndFeel). Each passed the old class-specific id; the
m: map only has the new dispatch id; the hierarchy walk found
nothing; ``Missing virtual method`` exploded.

Rather than update every call site, normalise in ``resolveVirtual``:
if the id starts with ``cn1_`` but not ``cn1_s_``, walk the class
index to find the longest matching ``cn1_<class>_`` prefix and
rewrite the id to ``cn1_s_<tail>``. Invariant-preserving for callers
that already pass the dispatch-id form.

Thread.start dispatch also updated to pass ``cn1_s_run`` directly
since the lookup is hot-path. A comment notes the compat shim in
resolveVirtual would have handled it regardless.

JavaScriptPortSmokeIntegrationTest 14/14 + Opcode coverage +
RuntimeSemantics array-covariance still pass (30 total). Manual
HelloCodenameOne worker replay: no more ``Missing virtual method``
errors; worker hangs only on the absent main-thread bridge for the
first host callback (expected for the standalone replay).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3730764 re-applied bindNative overrides in installNativeBindings
across every class whose methods map carried the matching dispatch
id. That works in the pre-fa4247a42 world where each class had its
own ``cn1_<class>_<method>_<sig>`` entry — at most one class would
match. With sig-based dispatch ids, ``cn1_s_<method>_<sig>`` is the
SAME key across every class that implements that method. The
override loop was clobbering every subclass's own implementation
with the bindNative for one parent class.

Concrete reproducer: ``com_codename1_util_StringUtil.tokenize``
silently returned an empty ArrayList in worker_threads context
because some Object-level bindNative (toString / equals / etc.) had
overwritten ArrayList's translated method via the dispatch-id alias.
The CN1 core slice test went from result=3 to result=0 between
4ecac79 and 3730764 for exactly this reason.

Restrict the dispatch-id override to ONLY the class extracted from
the bindNative's class-specific name. Class-specific name overrides
still apply globally (no key collision risk since each class has its
own entry). Also drop the ``jvm.nativeMethods[dispatchId] = fn``
pollution from 3730764 — the resolveVirtual fallback at line 924
would have synthesised a global override there too.

JavascriptCn1CoreCompletenessTest now 2/2 pass locally (was 1 fail
since fa4247a). Smoke + Opcode coverage also still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The case-merge optimization collapses bare-label cases (line numbers,
local-var range markers, try-range start/end) into the next executable
case's body — emitting ``case 0: case 1: case 3: { body }`` where the
body lives at instruction index 3. JS switch dispatch enters the body
on any of those labels, but the ``pc`` variable still holds whichever
label dispatched.

Normally fine: the body's trailing ``pc = N+1; break`` overwrites pc
before the next switch iteration. But when the body's first
instruction throws (e.g. ``yield* cn1_java_lang_Thread_sleep_long``
during InterruptSleepWorker.run), the outer ``catch (__cn1Error) {
pc = _E(table, pc, err, stack); }`` invokes
``findExceptionHandler(table, pc, err)`` with the stale entry pc.
findExceptionHandler then skips an otherwise-matching try-range entry
because the [s, e) interval doesn't contain that label.

Concrete failure: ``InterruptedException`` propagated past the
``catch (InterruptedException err)`` block in the
``executesThreadWaitSleepJoinAndInterruptInWorkerRuntime`` fixture —
result was missing bits 64/128/256 from the InterruptSleepWorker
catch-block body.

When a method has any try/catch table AND the about-to-emit block
contains a throwing instruction, emit ``pc = i;`` at block start so
the ``catch`` sees the actual instruction's index. Cheap (~7 chars
per qualifying block) and only fires for methods that need it.

Local: JavascriptRuntimeSemanticsTest 338/338 across the smoke +
opcode + cn1-core + thread-semantics suites. Was 29 fail prior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related correctness fixes for state that survives across the
translator's per-fixture invocations and for translator class-emit
order assumptions:

1. ``JavascriptMethodGenerator.classIndex`` is a static field. The
   gating check ``classIndex == null || classIndex.size() != allClasses
   .size()`` only rebuilds when the count changes — so when the test
   harness ran fixture B with the SAME number of classes as fixture A
   (Parser.cleanup() between runs, but the JavaAPI baseline is fixed
   size), the index kept pointing at fixture A's ByteCodeClass
   instances. javac 17+ generated the synthetic ``$values()`` enum
   helper for the fresh fixture's Mode class, but ``resolveDirectInvokeTarget``
   walked the stale instance which had no ``$values`` — returned null
   — so ``isInvokeSuspending`` defaulted to true and Mode.<clinit>
   emitted ``yield* cn1_..._Mode__values()`` from a non-generator
   wrapper. Result: ``ReferenceError: yield is not defined`` the
   moment any code triggered Mode's clinit.
   Add an identity check so a swapped ByteCodeClass for the
   currently-emitting class also forces a rebuild.

2. The ``defineClass`` auto-populate for ``assignableTo`` walks
   ``baseClass.assignableTo`` to union ancestor entries, but the
   translator emits classes in source order, not inheritance order:
   IllegalStateException can be defined BEFORE RuntimeException, so
   the lookup ``this.classes["java_lang_RuntimeException"]`` returns
   null and the chain walk terminates at the first hop. Result:
   ``IllegalStateException.assignableTo`` was just
   ``{IllegalStateException, Object}`` — missing every superclass —
   so a ``catch (RuntimeException)`` block didn't match and the
   exception propagated past it. Concrete reproducer: the
   ``IllegalStateException -> RuntimeException`` catch arm in
   JsJavaApiCoverageApp's main returned mask=0 instead of 64.
   - Pin ``def.baseClass`` directly into the auto-populated map so
     the immediate parent always matches.
   - Add ``jvm.assignableViaAncestors(className, target)`` that walks
     the baseClass + interfaces string chain at query time. Used as a
     fallback in ``findExceptionHandler``, ``instanceOf``, ``jvm.cC``
     (CHECKCAST helper) and ``jvm.iO`` (INSTANCEOF helper). Caches
     the resolved entry back into the original assignableTo map so
     subsequent lookups stay O(1).

Local: JavaScriptPortSmokeIntegrationTest 14/14 + Opcode coverage +
Cn1Core completeness + JavascriptRuntimeSemanticsTest's broader
JavaAPI + thread-semantics tests now pass (45 total). The two
remaining ParparVM Java Tests failures (CN1 core slice + thread
semantics) are resolved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 25, 2026

✅ ByteCodeTranslator Quality Report

Test & Coverage

  • Tests: 644 total, 0 failed, 2 skipped

Benchmark Results

  • Execution Time: 10955 ms

  • Hotspots (Top 20 sampled methods):

    • 23.01% java.lang.String.indexOf (440 samples)
    • 18.83% java.util.ArrayList.indexOf (360 samples)
    • 17.78% com.codename1.tools.translator.Parser.isMethodUsed (340 samples)
    • 5.60% java.lang.Object.hashCode (107 samples)
    • 3.19% com.codename1.tools.translator.Parser.addToConstantPool (61 samples)
    • 2.98% java.lang.System.identityHashCode (57 samples)
    • 2.93% com.codename1.tools.translator.ByteCodeClass.markDependent (56 samples)
    • 1.99% com.codename1.tools.translator.ByteCodeClass.updateAllDependencies (38 samples)
    • 1.36% com.codename1.tools.translator.BytecodeMethod.optimize (26 samples)
    • 1.26% com.codename1.tools.translator.ByteCodeClass.calcUsedByNative (24 samples)
    • 1.26% java.lang.StringBuilder.append (24 samples)
    • 1.26% com.codename1.tools.translator.Parser.generateClassAndMethodIndexHeader (24 samples)
    • 0.84% com.codename1.tools.translator.Parser.cullMethods (16 samples)
    • 0.84% com.codename1.tools.translator.Parser.getClassByName (16 samples)
    • 0.68% com.codename1.tools.translator.ByteCodeClass.isDefaultInterfaceMethod (13 samples)
    • 0.68% sun.nio.fs.UnixNativeDispatcher.open0 (13 samples)
    • 0.63% com.codename1.tools.translator.BytecodeMethod.appendMethodSignatureSuffixFromDesc (12 samples)
    • 0.63% com.codename1.tools.translator.BytecodeMethod.appendCMethodPrefix (12 samples)
    • 0.58% com.codename1.tools.translator.BytecodeMethod.isMethodUsedByNative (11 samples)
    • 0.52% sun.nio.cs.UTF_8$Encoder.encode (10 samples)
  • ⚠️ Coverage report not generated.

Static Analysis

  • ✅ SpotBugs: no findings (report was not generated by the build).
  • ⚠️ PMD report not generated.
  • ⚠️ Checkstyle report not generated.

Generated automatically by the PR CI workflow.

shai-almog and others added 6 commits April 25, 2026 09:21
JavascriptReachability.enqueueResolved walked only the
``baseClass`` chain when resolving a virtual receiver type, never
the implemented interfaces. Java 8+ default methods live on the
interface side of the lattice — when a class (most often a lambda
class) implements an interface that declares a default method but
doesn't override it, dispatch lands on the interface's body. RTA
saw "no concrete impl found by walking the extends chain", culled
the default method, and the runtime's ``resolveVirtual`` raised
``Missing virtual method`` the moment the call site fired.

Concrete reproducer (HelloCodenameOne screenshot test):
DefaultMethodDemo's ``BaseFormatter`` interface declares ``process``
abstract and ``format`` default. Three lambdas implement
BaseFormatter / its sub-interfaces, only one overrides ``process``
— ``format`` is inherited. RTA culled BaseFormatter.format → call
site exploded with ``Missing virtual method
cn1_s_format_java_lang_String_R_java_lang_String on
DefaultMethodDemo_lambda_0``.

Add ``enqueueInterfaceDefault`` as a fallback when the extends-walk
finds no concrete match: walk the interfaces of the receiver class
(and its supertypes' interfaces, transitively), enqueue the first
non-abstract match. Java's interface-resolution spec guarantees at
most one maximally-specific concrete default per signature on the
lattice, so the first hit is correct.

JavaScriptPortSmokeIntegrationTest 14/14 + Opcode coverage +
Cn1Core completeness all still green locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression harness covering the long fa4247a8aebf41 chain that
took the Initializr bundle from "stuck on Loading…, no console
output" to a clean boot. Loads each requested bundle in headless
Chromium, polls the translator-emitted ``PARPAR-LIFECYCLE`` markers
and the ``cn1Initialized`` / ``cn1Started`` lifecycle flags, and
asserts the app reaches both within a per-bundle timeout. Captures
the most recent ``PARPAR:DIAG:FIRST_FAILURE`` and the last few
``PARPAR-LIFECYCLE`` lines in the failure report so the stalled
milestone is visible without trawling the full browser log.

Concrete regressions it catches (each one bit me during the recent
debugging):

* No ``PARPAR-LIFECYCLE`` markers at all — the always-on
  ``vmLifecycle`` log was the only signal during the original
  loading hang. Bundles missing that runtime addition print
  "(no PARPAR-LIFECYCLE markers — runtime never produced one)".
* ``cn1Initialized`` set but ``cn1Started`` never set — the boot
  reaches ``Lifecycle.init`` but stalls inside ``Lifecycle.start``.
  Reports both flags so it's clear which side hung.
* ``__parparError`` populated by the runtime — covers the chain of
  ``Missing virtual method`` / ``Missing JS member`` / yield-in-non-
  generator errors we worked through.

Reuses the existing ``javascript_browser_harness.py`` for log capture
(injecting ``/__cn1__/probe.js`` into the served ``index.html`` the
same way ``run-javascript-browser-tests.sh`` does), so the captured
``browser.log`` artifact matches the screenshot-test format.

Defaults to the two CI bundles:
  scripts/hellocodenameone/parparvm/target/hellocodenameone-javascript-port.zip
  scripts/initializr/javascript/target/initializr-javascript-port.zip

The shell wrapper builds the bundles via ``mvn package
-Pjavascript-build`` if they're missing; ``CN1_LIFECYCLE_SKIP_BUILD=1``
opts out for environments that already have artifacts.

Verified locally: against the pre-vmLifecycle Initializr bundle the
test correctly reports cn1Initialized=true / cn1Started=false plus
"(no PARPAR-LIFECYCLE markers — runtime never produced one)" and
exits non-zero.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diagnosis: ``window.cn1Started`` flips correctly inside the worker
when ParparVMBootstrap.setStarted runs (the @JSBody body executes in
the worker's global, so ``window === self`` and the assignment
lands), but the BROWSER's ``window.cn1Started`` (read by the
playwright test and by every CI consumer that polls the main-thread
DOM) stays false forever.

The bridge's pre-existing fallbacks for setting cn1Started on the
main-thread side were:

  1. ``data.type === 'result'`` — only fires when the app calls
     ``System.exit``. CN1SS test fixtures hit this; a regular
     application that reaches its first form and waits for input
     does not.
  2. A worker LOG message containing both ``CN1JS:`` and
     ``.runApp`` — nothing in the codebase emits that. (The probe
     goes back to a TeaVM era.)

So an actual app boot — ``Lifecycle.init`` returns,
``Lifecycle.start`` returns, ``runApp`` returns — produced no
worker→main signal at all and the bridge sat with cn1Started=false
indefinitely. Visible as the lifecycle test ``[FAIL] cn1Started=false``
even after every host callback resolved successfully.

Fix:

* Add a ``LIFECYCLE`` protocol message and have the worker emit
  ``{type: 'lifecycle', phase: 'started'}`` exactly once, when the
  main-thread generator completes (drain detects ``result.done``
  on a thread whose object matches ``mainThreadObject``). At that
  point ParparVMBootstrap.run() is unwinding past setStarted's
  @JSBody, so we know lifecycle.start has returned and the app
  is in steady state.
* Bridge: ``handleVmMessage`` recognises the new message and sets
  ``global.cn1Started = true``.

Also adds always-on ``main-thread-completed`` and
``main-host-callback`` lifecycle log lines so a future failure can
distinguish "main thread blocked on a host call that never resolved"
from "host callbacks fine but main never finishes" from "main
finished but bridge didn't propagate cn1Started".

CI: hooks the new ``run-javascript-lifecycle-tests.mjs`` into the
existing ``Test JavaScript screenshot scripts`` workflow as a
pre-screenshot step. Lifecycle test takes ~30s and gives fast
feedback for boot regressions before the 3-minute screenshot run.
Uploads its own ``javascript-lifecycle-tests`` artifact (per-bundle
browser log + report.json) so a CI failure has the diagnostic data
attached.

Verified locally:

* Fresh HelloCodenameOne bundle (rebuilt with this change) →
  ``[OK] hellocodenameone-javascript-port  cn1Initialized=true
  cn1Started=true`` after 96 main-host-callbacks then
  ``main-thread-completed``.
* Smoke + Opcode coverage + Cn1Core completeness still 19/19 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Earlier fix (4935405) emitted the worker→main lifecycle message
when the MAIN thread generator finished — i.e. after Lifecycle.init
+ Lifecycle.start + every host call queued during init. CI runners
process bytecode-translator output ~6× slower than local: locally
the main thread completes after ~180s of cooperative-scheduling
host callbacks, on CI it doesn't reach completion within the 120s
test timeout.

Move the message emission into the @JSBody body of
``ParparVMBootstrap.setStarted`` so the signal fires the moment
``Lifecycle.start()`` returns — i.e. as soon as the bootstrap
synchronously reaches setStarted's call site, before any of the
queued runApp() runnables execute. The script:

    window.cn1Started = true;
    var msg = {type: 'lifecycle', phase: 'started'};
    if (typeof parentPort !== 'undefined' ...) parentPort.postMessage(msg);
    else if (typeof self !== 'undefined' && self !== this ...) self.postMessage(msg);
    else if (typeof postMessage === 'function') postMessage(msg);

Three-way fallback because the @JSBody runs in three different
worker shapes: Node ``worker_threads`` (parentPort), browser Worker
(self.postMessage), and direct in-page invocation from the
JavaScript-port simulator (top-level postMessage). The drain-side
emit at ``main-thread-completed`` stays as a backstop for any code
path that bypasses the bootstrap (e.g. a unit test that calls
``jvm.spawn`` directly).

Same workflow change bumps ``CN1_LIFECYCLE_TIMEOUT_SECONDS`` to 240
just in case — the bootstrap fires the message early so most apps
hit cn1Started in seconds, but the headroom protects against future
init paths that take longer.

Local timing: lifecycle test went from ``[OK]`` after 180s (waiting
for main-thread-completed) to ``[OK]`` after ~10s (waiting for
setStarted). Smoke + Opcode coverage + Cn1Core completeness still
19/19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Screenshot suite was completing too quickly with no test chunks
because every BaseTest's prepare()/runTest() call from
runCn1ssResolvedTest threw ``TypeError: yield* (intermediate value)
... is not iterable``. Root cause: AbstractTest.prepare() has an
empty body, has no INVOKEVIRTUAL/INVOKEINTERFACE, and the CHA
suspension analysis (since fa4247a) correctly classifies it as
plain ``function`` returning ``undefined``. ``yield* undefined``
is fatal.

The cn1_iv* helper family already handles this contract — they
forward iterator results via yield* and return sync results via
``adaptVirtualResult``. Expose that helper as ``global.cn1_ivAdapt``
and have port.js's resolveVirtual+yield-delegate sites route
through it. Replace the two BaseTest dispatches first since those
were the visible failure; other yield*-on-resolveVirtual patterns
will be migrated as they show up.

Local: lifecycle test still ``[OK]`` (10s), Java suite 19/19 still
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CHA suspension analysis classifies any method whose body has
no INVOKEVIRTUAL/INVOKEINTERFACE as a plain (sync) ``function`` —
including empty bodies like ``Object.<init>``,
``AbstractTest.prepare``, ``AbstractTest.cleanup``, and many
overrides whose intended behavior is "no-op". Hand-written code in
port.js and parparvm_runtime.js calls these via
``yield* translatedFn(args)``, which throws ``TypeError: yield*
(intermediate value) is not iterable`` when the result is the
sync method's plain ``undefined`` return.

Two related fixes:

* Expose ``adaptVirtualResult`` as ``global.cn1_ivAdapt`` (same
  helper ``cn1_iv*`` use internally — forwards iterator results
  via yield*, returns sync results unchanged).
* Wrap the known-fragile call sites:
    - port.js ``cn1_kotlin_Unit___INIT__`` → Object.<init> dispatch
    - parparvm_runtime.js HashMap key-equality + LinkedHashMap
      findNonNullKeyEntry + InputStreamReader/NSLog bytesToChars
      delegations.
  Each goes from ``yield* translatedFn(args)`` to
  ``yield* adaptVirtualResult(translatedFn(args))`` (or
  ``cn1_ivAdapt`` for port.js's exposed call).

Concrete reproducer (HelloCodenameOne screenshot suite): every
BaseTest's prepare/runTest dispatch threw the iterability error
during the ``runtTest`` phase, then KotlinUiTest's clinit chain
through ``kotlin_Unit.clinit`` threw the same error one level
deeper because Object.<init> got CHA-classified-sync. The screenshot
runner's lambda3 fallback rolled the suite forward to
``CN1SS:SUITE:FINISHED`` with zero successful test runs, so the
post-suite chunk parser saw 0 chunks and exited with code 12.

Local: lifecycle test ``[OK]`` (10s), smoke + opcode 17/17 still
green. Other yield*-on-translated-method sites in port.js
(SetVisible / SetEnabled / styleChanged shims, Form lifecycle
shims, font-metrics shims) will be migrated as they surface — no
visible failures yet but they share the same fragility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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