Skip to content

feat(workers): move worker threading and messaging to C++, mirroring the iOS runtime, and support SharedArrayBuffer#1972

Merged
NathanWalker merged 5 commits into
mainfrom
feat/worker-debug
Jun 12, 2026
Merged

feat(workers): move worker threading and messaging to C++, mirroring the iOS runtime, and support SharedArrayBuffer#1972
NathanWalker merged 5 commits into
mainfrom
feat/worker-debug

Conversation

@edusperoni

@edusperoni edusperoni commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

What

Replaces the Java Handler/HandlerThread-based worker implementation with a C++ one that mirrors the iOS runtime's WorkerWrapper, and adds SharedArrayBuffer support both in worker messaging and in JS→Java argument marshalling.

Why

Worker messaging previously round-tripped every postMessage through Java as JSON strings (WorkerGlobalOnMessageCallback, WorkerObjectOnMessageCallback, etc.), which meant no structured clone, no way to share SharedArrayBuffer memory between threads, and a worker lifecycle split awkwardly across Java and C++. Aligning with the iOS implementation gives both runtimes the same architecture and unlocks real structured-clone semantics.

How

New C++ worker infrastructure:

  • WorkerWrapper — owns the worker's std::thread and full lifecycle (start/close/terminate). The thread attaches to the JVM and prepares a Java Looper so worker/plugin code can keep using Android Handlers; that single looper pumps both Java messages and the worker's C++ inbox. Also hosts the live-worker registry (replacing CallbackHandlers::id2WorkerMap).
  • WorkerMessage — structured-clone payloads via v8::ValueSerializer/ValueDeserializer; SharedArrayBuffer contents cross threads by sharing their backing stores instead of copying.
  • ConcurrentQueue — thread-safe parent→worker inbox attached to the worker's ALooper via eventfd; messages posted before the worker finishes bootstrapping are drained after the worker script runs.
  • LooperTasks — per-runtime task queue, the Android analogue of iOS's ExecuteOnRunLoop; workers post messages, errors, and cleanup notifications to their parent runtime's queue. Held via weak_ptr by children, so a child outliving its parent safely drops its posts.

Worker semantics:

  • Nested workers: a worker's parent can be another worker; terminating a worker cascades to its children.
  • New androidPriority Worker option mapping to android.os.Process thread priorities (accepts THREAD_PRIORITY_* names or a clamped nice value; defaults to the previously hardcoded THREAD_PRIORITY_BACKGROUND).
  • Uncaught worker exceptions are passed to the parent's onerror as plain strings (no cross-isolate handles).

SharedArrayBuffer marshalling:

  • JsArgConverter, JsArgToArrayConverter, and JSToJavaObjectsConverter now accept SharedArrayBuffer wherever ArrayBuffer/typed arrays were accepted, exposing it to Java as a direct ByteBuffer over the same memory (no copy).

Removed Java plumbing:

  • WorkerThreadHandler, mainThreadHandler, workerIdToHandler, pendingWorkerMessages, MessageType.java, JavaScriptErrorMessage.java, the runWorker native, and the worker JNI callbacks — Runtime.java shrinks by ~370 lines.

Tests

  • New shared-array-buffer-test.js covering SAB→ByteBuffer conversion, byteOffset/length of typed-array views over a SAB, and shared (zero-copy) memory semantics.

Summary by CodeRabbit

  • New Features

    • SharedArrayBuffer support for Java ByteBuffer and typed-array interop.
    • Looper-backed worker runtime with improved lifecycle, messaging, and per-runtime task queues.
    • Cross-isolate structured-clone messaging with SharedArrayBuffer transfer tracking.
    • Java ByteBuffer inspection API for holding and accessing buffers.
  • Tests

    • Added SharedArrayBuffer test suite covering conversions, byte-range views, shared-memory semantics, and cross-worker visibility.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 147e620c-3e3a-4e8b-a15e-377b8efd5138

📥 Commits

Reviewing files that changed from the base of the PR and between 6b12678 and 18877fe.

📒 Files selected for processing (1)
  • test-app/runtime/src/main/java/com/tns/Runtime.java
🚧 Files skipped from review as they are similar to previous changes (1)
  • test-app/runtime/src/main/java/com/tns/Runtime.java

📝 Walkthrough

Walkthrough

Refactors worker lifecycle to Looper-backed native threads, adds V8 structured-clone messaging with SharedArrayBuffer tracking, implements eventfd/ALooper-backed task/message queues, introduces WorkerWrapper for lifecycle and messaging, updates runtime cache synchronization and Java lifecycle APIs, and adds SharedArrayBuffer tests.

Changes

Worker Thread Architecture and Message System Refactoring

Layer / File(s) Summary
SharedArrayBuffer recognition & conversion
test-app/runtime/src/main/cpp/MethodCache.cpp, test-app/runtime/src/main/cpp/JsArgConverter.cpp, test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp, test-app/runtime/src/main/cpp/conversions/objects/JSToJavaObjectsConverter.cpp, test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js, test-app/app/src/main/assets/app/mainpage.js
Treat SharedArrayBuffer and additional typed-array kinds as "bytebuffer"; convert backing stores to Java direct ByteBuffer equivalents; add tests validating round-trip, offset/length, shared semantics, and worker-based visibility.
V8 structured message serialization (WorkerMessage)
test-app/runtime/src/main/cpp/WorkerMessage.h, test-app/runtime/src/main/cpp/WorkerMessage.cpp
Add tns::worker::Message with ValueSerializer/ValueDeserializer delegates for structured-clone messaging, including SharedArrayBuffer tracking and malloc-backed payloads.
ConcurrentQueue & LooperTasks (ALooper integration)
test-app/runtime/src/main/cpp/ConcurrentQueue.h, test-app/runtime/src/main/cpp/ConcurrentQueue.cpp, test-app/runtime/src/main/cpp/LooperTasks.h, test-app/runtime/src/main/cpp/LooperTasks.cpp
Eventfd-backed ConcurrentQueue and LooperTasks implementations that register with Android ALooper to wake and drain queued messages/tasks from background threads.
WorkerWrapper lifecycle and messaging
test-app/runtime/src/main/cpp/WorkerWrapper.h, test-app/runtime/src/main/cpp/WorkerWrapper.cpp
New WorkerWrapper manages worker thread/Looper lifecycle, inbox queuing, message deserialization and onmessage delivery, posting messages to parent, uncaught-exception forwarding, and global worker registry.
Runtime integration and cache synchronization
test-app/runtime/src/main/cpp/Runtime.h, test-app/runtime/src/main/cpp/Runtime.cpp
Per-runtime LooperTasks created and initialized; runtime caches guarded by s_runtimeCacheMutex; RunWorker now accepts const std::string& filePath; removed PassUncaughtExceptionFromWorkerToMainHandler and updated isolate/global worker setup.
CallbackHandlers routing to WorkerWrapper & error handling
test-app/runtime/src/main/cpp/CallbackHandlers.h, test-app/runtime/src/main/cpp/CallbackHandlers.cpp
Route postMessage/close/terminate and error bubbling through WorkerWrapper and worker::Message serialization; remove CallbackHandlers id2WorkerMap/nextWorkerId and INIT_WORKER_METHOD_ID; add GetWorkerThreadPriority and ExtractTryCatchInfo-based error routing.
JNI and Java API cleanup / new lifecycle methods
test-app/runtime/src/main/cpp/com_tns_Runtime.cpp, test-app/runtime/src/main/java/com/tns/Runtime.java, test-app/runtime/src/main/java/com/tns/JavaScriptErrorMessage.java, test-app/runtime/src/main/java/com/tns/MessageType.java
Remove old JNI worker entrypoints and worker messaging natives; delete obsolete Java message/error helper classes; add new Java lifecycle entry points (initWorkerRuntime, runWorkerLoop, detachWorkerRuntime) and reorganize runtime state for Looper-based workers.
Build wiring
test-app/runtime/CMakeLists.txt
Add new C++ sources (ConcurrentQueue.cpp, LooperTasks.cpp, WorkerMessage.cpp, WorkerWrapper.cpp) to the NativeScript shared library build.
Tests & Java helper
test-app/app/src/main/java/com/tns/tests/ByteBufferHolder.java, test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js
Add ByteBufferHolder Java helper to hold a ByteBuffer and tests exercising SharedArrayBuffer round-trip, offset/length handling, shared visibility, and cross-isolate mutation visibility.

Sequence diagram (high-level parent ↔ worker flow):

sequenceDiagram
  participant JSMain as Parent Runtime
  participant WorkerWrapper as Native WorkerWrapper
  participant WorkerIsolate as Worker V8 Isolate
  JSMain->>WorkerWrapper: PostMessage(worker::Message)
  WorkerWrapper->>WorkerIsolate: onmessage({data})
  WorkerIsolate->>WorkerWrapper: PostMessageToParent(worker::Message)
  WorkerWrapper->>JSMain: invoke parent worker.onmessage({data})
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

"🐰 A looper hums where threads once slept,
Shared buffers leap where bytes were kept,
V8 wraps messages, cross-isolate and neat,
Eventfd taps wake the queue's heartbeat,
The rabbit cheers — the refactor's complete."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly addresses the main change: moving worker implementation to C++ and adding SharedArrayBuffer support, which aligns with the substantial architectural refactoring present throughout the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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 and usage tips.

… a C++ implementation mirroring iOS

Previously each Worker ran on a Java HandlerThread and every postMessage
was stringified to JSON in JS, marshalled to Java as a String, routed
between threads with android.os.Handler/Message, and re-entered V8
through five JNI callbacks. Workers could only be created from the main
thread, and SharedArrayBuffer could not cross threads at all.

Worker threading and lifecycle now live in C++ (WorkerWrapper, modeled
on the iOS runtime's class of the same name):

- WorkerWrapper owns a detached std::thread per worker that holds a
  shared_ptr to the wrapper until shutdown. The thread attaches to the
  JVM, calls Runtime.initWorkerRuntime (which prepares a Java Looper
  before the native runtime binds its fds, and creates the worker's
  Java Runtime with a Looper-backed scheduler so Android Handlers and
  cross-thread Java->JS calls keep working), runs the worker script,
  drains messages posted before bootstrap finished, then blocks in
  Looper.loop(). A single looper per worker pumps both Java messages
  and the native fds.

- Messaging no longer round-trips through Java or JSON. Payloads are
  serialized with v8::ValueSerializer (structured clone): ArrayBuffers
  and typed arrays are deep-copied; SharedArrayBuffer contents are
  shared across isolates via their backing stores; Java proxy host
  objects are recreated as plain objects. parent->worker delivery uses
  a ConcurrentQueue woken through an eventfd on the worker's looper;
  worker->parent delivery (messages, onerror, cleanup) posts to the
  parent runtime's new LooperTasks queue - the Android analogue of the
  iOS runtime's ExecuteOnRunLoop. LooperTasks is held via weak_ptr by
  children, so posts to an already-destroyed parent are dropped safely.

- The Worker constructor is attached to every isolate's global, not
  just the main one, enabling nested workers (parity with iOS). The
  worker registry (replacing CallbackHandlers::id2WorkerMap) is
  mutex-guarded, and terminating a worker - or disposing its isolate -
  cascades termination to its children.

- New `androidPriority` Worker option: accepts THREAD_PRIORITY_* names
  or an integer nice value clamped to [-20, 19]; defaults to
  THREAD_PRIORITY_BACKGROUND (10), the previously hardcoded value.

- Runtime's id->runtime and isolate->runtime caches are now
  mutex-guarded since runtimes are created/destroyed off the main
  thread, each Runtime owns a LooperTasks bound to its looper, and a
  new WORKER_WRAPPER isolate-data slot resolves a worker isolate back
  to its wrapper.

SharedArrayBuffer is also now accepted in JS->Java argument marshalling
(JsArgConverter, JsArgToArrayConverter, JSToJavaObjectsConverter): it
converts to a direct ByteBuffer over the same memory, like ArrayBuffer.
Covered by the new shared-array-buffer-test.js (conversion, view
offset/length, zero-copy sharing).

Removed now-dead plumbing: the runWorker JNI entry point, the
WorkerGlobalOnMessageCallback / WorkerObjectOnMessageCallback /
TerminateWorkerCallback / ClearWorkerPersistent /
CallWorkerObjectOnErrorHandleMain JNI callbacks, WorkerThreadHandler,
pendingWorkerMessages, workerIdToHandler, MessageType.java and
JavaScriptErrorMessage.java.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test-app/runtime/src/main/cpp/CallbackHandlers.cpp (1)

1200-1210: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Validate the receiver before reading the private workerId.

Both prototype methods assume args.This() is a real Worker instance. Worker.prototype.postMessage.call({}, ...) or .terminate.call({}) leaves jsId unset, and the unconditional Int32Value(...).ToChecked() turns that into a native crash/assert rather than a JS TypeError.

Also applies to: 1305-1312

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp` around lines 1200 - 1210,
The code reads the private "workerId" unconditionally and calls
jsId->Int32Value(...).ToChecked(), causing native crashes when args.This() isn't
a real Worker; instead, validate the receiver and the result of
V8GetPrivateValue before converting: check the return (maybejsId) or whether
jsId is empty and whether jsId->IsInt32() (or ToInt32 succeeded); if validation
fails, throw a JS TypeError via the current isolate/context and return early.
Update both the block around V8GetPrivateValue/Int32Value (where jsId and id are
used) and the similar block at lines 1305-1312 to perform these checks and use
ArgConverter::ConvertToV8String for the private key as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js`:
- Around line 62-74: The test can hang because it only calls done() from
worker.onmessage; add a worker error/timeout path: attach worker.onerror and
worker.onmessageerror handlers that call done.fail(...) (or fail the test) and
terminate the worker, and implement a short watchdog timer (clear it on success)
that fails the spec and terminates the worker if no message/error arrives within
e.g. a few seconds; update the block that creates
Worker("../shared/Workers/EvalWorker.js"), worker.postMessage(...), and
worker.onmessage to also set up these handlers and the timeout so the spec
always completes on error or timeout.

In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp`:
- Around line 995-1052: GetWorkerThreadPriority currently only recognizes custom
lowercase aliases for options.androidPriority and throws for Android constants;
update the string-handling branch in GetWorkerThreadPriority to accept
documented THREAD_PRIORITY_* names (e.g., "THREAD_PRIORITY_BACKGROUND",
"THREAD_PRIORITY_FOREGROUND", etc.) by normalizing the input (trim, uppercase)
and stripping an optional "THREAD_PRIORITY_" prefix before comparing against the
existing aliases
("LOWEST","BACKGROUND","LESSFAVORABLE","DEFAULT","MOREFAVORABLE","FOREGROUND","DISPLAY","URGENTDISPLAY","VIDEO","AUDIO","URGENTAUDIO");
map those normalized names to the same numeric values the existing lowercase
checks return so callers passing either form succeed.

In `@test-app/runtime/src/main/cpp/ConcurrentQueue.cpp`:
- Around line 23-29: Push() can enqueue messages after Terminate() tears down
the wakeup FD causing stranded messages; fix by introducing and checking a
lifecycle flag under the same mutex used in Push (e.g., add a bool like
terminating_ or shutdown_ protected by mutex_), have
Push(std::shared_ptr<worker::Message>) check that flag while holding mutex_ and
reject/drop (or return an error) instead of pushing into messagesQueue_ when
shutdown is in progress, and modify Terminate() to acquire the same mutex_, set
the flag, clear messagesQueue_ (or drain it safely), then call SignalAndWakeUp()
so no messages remain stranded; apply the same pattern to the code paths
referenced in 54-69.
- Around line 18-20: ConcurrentQueue::Initialize and LooperTasks::Initialize
must check and handle failures from eventfd() and ALooper_addFd(): after calling
eventfd(), verify the returned fd is >= 0 and on error return/fail
initialization and do not proceed; after calling ALooper_addFd(), check its
return (success/failure) and if it fails, close the created fd and call
ALooper_release() for the looper acquired earlier before returning an error;
ensure no path leaves a valid fd or held looper without a registered callback
(i.e., on any failure unwind by closing fd_ and releasing looper_), propagate
the failure status to the caller so queued work cannot silently accumulate.

In `@test-app/runtime/src/main/cpp/WorkerMessage.cpp`:
- Around line 29-33: The current WriteHostObject
(SerializerDelegate::WriteHostObject) and DeserializerDelegate::ReadHostObject
silently accept host objects and materialize empty objects; instead, detect
unsupported host objects and surface a DataCloneError via the V8 isolate and
return Nothing<bool>() from WriteHostObject (and return Nothing/throw in
ReadHostObject) per the V8 delegate contract. Update WriteHostObject to not
return Just(true) when the object is not serializable: call the isolate
exception throw helper for a DataCloneError (or v8 equivalent) and return
Nothing<bool>(). Likewise update ReadHostObject to throw the same DataCloneError
and return the appropriate Nothing result rather than creating a blank object.
Ensure you reference the existing SerializerDelegate::WriteHostObject and
DeserializerDelegate::ReadHostObject symbols when making these changes.

In `@test-app/runtime/src/main/cpp/WorkerMessage.h`:
- Around line 11-14: Malloc<T> currently allocates n bytes but callers (and
UncheckedRealloc) treat n as an element count, causing under-allocation for
non-char T; modify the template Malloc<T> to allocate n * sizeof(T) bytes (i.e.,
multiply n by sizeof(T)) and make the same change for the other Malloc
overloads/usages referenced (see UncheckedRealloc and MallocedBuffer<T>) so all
allocations use element count * sizeof(T) consistently.

In `@test-app/runtime/src/main/cpp/WorkerWrapper.cpp`:
- Around line 54-69: PostMessageToParent currently only checks isTerminating_,
so messages can still be posted after Close() begins; update
WorkerWrapper::PostMessageToParent to also check the "closing" state (the flag
set by Close(), e.g., isClosing_ or whatever Close() toggles) and return early
if either isClosing_ or isTerminating_ is true, and apply the same check to the
other PostMessageToParent overload/duplicate referenced (lines 92-102) so both
paths drop pending messages once Close() has started.
- Around line 274-299: The code currently stops at DEBUG_WRITE when no onerror
handler exists or it returns falsy, which silently swallows the worker failure;
update the path after the callback check in WorkerWrapper.cpp (symbols:
hasOnError, errEvent, callback, DEBUG_WRITE) to surface the error to the parent
runtime instead of only logging: build a V8 exception/value from
message/stackTrace/filename/lineno (e.g. Exception::Error or a proper Error
object) and either throw it into the current isolate
(isolate->ThrowException(...)) or invoke the parent error propagation mechanism
so the application receives the uncaught worker error; keep the existing
DEBUG_WRITE but ensure you call the chosen propagation (throw or host error
dispatch) when result is empty or falsy.
- Around line 333-356: The teardown can run with a null isolate if exceptions
occur before workerIsolate_ is published; after calling
Runtime::GetRuntime(runtimeId) and obtaining Isolate* isolate, immediately
publish it via workerIsolate_.store(isolate) (before calling MY_LOOPER /
NewGlobalRef / queue_.Initialize) and/or make the shutdown path defensively
check workerIsolate_.load() for null before entering v8::Locker / Isolate::Scope
/ Dispose(); update references to Runtime::GetRuntime, workerIsolate_.store,
MY_LOOPER/NewGlobalRef, queue_.Initialize, and the teardown that uses
v8::Locker/Isolate::Scope/Dispose accordingly.

In `@test-app/runtime/src/main/java/com/tns/Runtime.java`:
- Around line 488-493: The bootstrap path is registering a Runtime into
currentRuntime and runtimeCache before init() and
runScript("internal/ts_helpers.js") complete, causing leaked/stale runtimes if
bootstrap throws; update initializeRuntimeWithConfiguration/initRuntime to
either move the cache registration (currentRuntime and runtimeCache) until after
init() and runScript() succeed, or add a rollback in initRuntime that catches
bootstrap exceptions and removes the partially-registered entries
(currentRuntime and runtimeCache) and unsubscribes any GcListener/worker hooks
(and ensures detachWorkerRuntime is invoked for worker startup failure) so
failed startup cannot leave a discoverable dead runtime.
- Around line 488-491: The initializeRuntimeWithConfiguration method currently
constructs WorkThreadScheduler with Looper.myLooper(), which allows callers on
background loopers to bind the runtime to the wrong looper; change it to verify
the caller is on the real main looper and use Looper.getMainLooper() to build
the WorkThreadScheduler. Specifically, in initializeRuntimeWithConfiguration
check Thread/Looper identity and throw an IllegalStateException (or similar) if
Looper.myLooper() != Looper.getMainLooper(), then create new
WorkThreadScheduler(new Handler(Looper.getMainLooper())) so
getMainThreadHandler() and isNotOnMainThread() consistently reference the true
main looper.

---

Outside diff comments:
In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp`:
- Around line 1200-1210: The code reads the private "workerId" unconditionally
and calls jsId->Int32Value(...).ToChecked(), causing native crashes when
args.This() isn't a real Worker; instead, validate the receiver and the result
of V8GetPrivateValue before converting: check the return (maybejsId) or whether
jsId is empty and whether jsId->IsInt32() (or ToInt32 succeeded); if validation
fails, throw a JS TypeError via the current isolate/context and return early.
Update both the block around V8GetPrivateValue/Int32Value (where jsId and id are
used) and the similar block at lines 1305-1312 to perform these checks and use
ArgConverter::ConvertToV8String for the private key as before.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a4db806d-b4e2-4ac1-b632-a81e5dbb61c1

📥 Commits

Reviewing files that changed from the base of the PR and between 55da2da and 04a92cb.

📒 Files selected for processing (23)
  • test-app/app/src/main/assets/app/mainpage.js
  • test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js
  • test-app/runtime/CMakeLists.txt
  • test-app/runtime/src/main/cpp/CallbackHandlers.cpp
  • test-app/runtime/src/main/cpp/CallbackHandlers.h
  • test-app/runtime/src/main/cpp/ConcurrentQueue.cpp
  • test-app/runtime/src/main/cpp/ConcurrentQueue.h
  • test-app/runtime/src/main/cpp/JsArgConverter.cpp
  • test-app/runtime/src/main/cpp/JsArgToArrayConverter.cpp
  • test-app/runtime/src/main/cpp/LooperTasks.cpp
  • test-app/runtime/src/main/cpp/LooperTasks.h
  • test-app/runtime/src/main/cpp/MethodCache.cpp
  • test-app/runtime/src/main/cpp/Runtime.cpp
  • test-app/runtime/src/main/cpp/Runtime.h
  • test-app/runtime/src/main/cpp/WorkerMessage.cpp
  • test-app/runtime/src/main/cpp/WorkerMessage.h
  • test-app/runtime/src/main/cpp/WorkerWrapper.cpp
  • test-app/runtime/src/main/cpp/WorkerWrapper.h
  • test-app/runtime/src/main/cpp/com_tns_Runtime.cpp
  • test-app/runtime/src/main/cpp/conversions/objects/JSToJavaObjectsConverter.cpp
  • test-app/runtime/src/main/java/com/tns/JavaScriptErrorMessage.java
  • test-app/runtime/src/main/java/com/tns/MessageType.java
  • test-app/runtime/src/main/java/com/tns/Runtime.java
💤 Files with no reviewable changes (3)
  • test-app/runtime/src/main/java/com/tns/MessageType.java
  • test-app/runtime/src/main/cpp/com_tns_Runtime.cpp
  • test-app/runtime/src/main/java/com/tns/JavaScriptErrorMessage.java

Comment on lines +62 to +74
var worker = new Worker("../shared/Workers/EvalWorker.js");
worker.postMessage({
value: sab,
eval: "var v = new Int32Array(value); v[0] = 1234; postMessage('written');"
});
worker.onmessage = function (msg) {
expect(msg.data).toBe("written");
// the worker's write is visible both to this isolate and to Java
expect(view[0]).toBe(1234);
expect(javaBuf.getInt(0)).toBe(1234);
worker.terminate();
done();
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate the file explicitly
FILE="test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js"
ls -la "$FILE"

# Show the relevant section around lines 50-90
nl -ba "$FILE" | sed -n '40,110p'

# Search in repo for other references to this test or similar patterns
rg -n "shared-array-buffer-test\.js|EvalWorker\.js|new Worker\\(\"\\.\\./shared/Workers/EvalWorker\.js\"\\)" -S .

# Inspect EvalWorker.js for any messaging/error behavior
WORKER="test-app/app/src/main/assets/app/tests/shared/Workers/EvalWorker.js"
ls -la "$WORKER" || true
if [ -f "$WORKER" ]; then
  nl -ba "$WORKER" | sed -n '1,220p'
fi

# Find any test harness utilities: "done", timeouts, or expect patterns
rg -n "function \\(msg\\)|onmessage|onerror|setTimeout\\(|jest\\.setTimeout|done\\(" -S test-app/app/src/main/assets/app/tests | head

Repository: NativeScript/android

Length of output: 241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js"
ls -la "$FILE"
nl -ba "$FILE" | sed -n '40,110p'

rg -n "shared-array-buffer-test\\.js|EvalWorker\\.js|new Worker\\(\"\\.\\./shared/Workers/EvalWorker\\.js\"\\)" -S .

WORKER="test-app/app/src/main/assets/app/tests/shared/Workers/EvalWorker.js"
ls -la "$WORKER" || true
if [ -f "$WORKER" ]; then
  nl -ba "$WORKER" | sed -n '1,260p'
fi

rg -n "onerror|addEventListener\\(['\"]error['\"]|setTimeout\\(|jest\\.setTimeout|done\\(" -S test-app/app/src/main/assets/app/tests | head -n 80

Repository: NativeScript/android

Length of output: 241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js"
ls -la "$FILE"
nl -ba "$FILE" | sed -n '40,110p'

echo "---- searching for worker error handling patterns ----"
rg -n "onerror|addEventListener\\(['\"]error['\"]|addEventListener\\(\\s*\"error\"|onmessageerror|messageerror|worker\\.onerror|worker\\.addEventListener\\(['\"]error['\"]" -S test-app/app/src/main/assets/app/tests/shared test-app/app/src/main/assets/app/tests | head -n 200 || true

echo "---- inspect EvalWorker.js ----"
WORKER="test-app/app/src/main/assets/app/tests/shared/Workers/EvalWorker.js"
ls -la "$WORKER" || true
if [ -f "$WORKER" ]; then
  nl -ba "$WORKER" | sed -n '1,260p'
fi

echo "---- search for similar tests that add onerror/timeout ----"
rg -n "new Worker\\(|postMessage\\(|terminate\\(\\);\\s*\\n\\s*done\\(|worker\\.terminate\\(\\)" -S test-app/app/src/main/assets/app/tests | head -n 200

Repository: NativeScript/android

Length of output: 241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js"
WORKER="test-app/app/src/main/assets/app/tests/shared/Workers/EvalWorker.js"

echo "== file exists =="
ls -la "$FILE"

echo "== shared-array-buffer-test.js (lines 1-170) =="
awk 'NR>=1 && NR<=170 { printf "%6d %s\n", NR, $0 }' "$FILE"

echo "== shared-array-buffer-test.js (lines 40-110) =="
awk 'NR>=40 && NR<=110 { printf "%6d %s\n", NR, $0 }' "$FILE"

echo "== search for worker error handling / timeout in this test folder =="
rg -n "onerror|addEventListener\\(['\"]error['\"]\\)|onmessageerror|messageerror|setTimeout\\(|jest\\.setTimeout|timeout" \
  -S test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js test-app/app/src/main/assets/app/tests/shared || true

echo "== inspect EvalWorker.js =="
if [ -f "$WORKER" ]; then
  ls -la "$WORKER"
  awk 'NR>=1 && NR<=220 { printf "%6d %s\n", NR, $0 }' "$WORKER"
else
  echo "MISSING: $WORKER"
fi

echo "== search for other shared-array-buffer tests with worker.onerror =="
rg -n "new Worker\\(|worker\\.onerror|addEventListener\\(['\"]error['\"]\\).*Worker|onmessageerror" \
  -S test-app/app/src/main/assets/app/tests || true

Repository: NativeScript/android

Length of output: 5318


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate EvalWorker.js by filename =="
fd -a -u -i "EvalWorker.js" . || true

echo "== locate any Workers/EvalWorker* =="
fd -a -u -i "*/EvalWorker.js" . || true

echo "== list directories named shared under this assets/tests =="
fd -a -u -i -t d "shared" test-app/app/src/main/assets/app/tests . || true

echo "== search for onerror handling in test-app/app/src/main/assets/app/tests =="
rg -n "worker\\.(onerror|addEventListener\\(['\"]error['\"])|onmessageerror|onerror\\s*=" \
  test-app/app/src/main/assets/app/tests -S || true

echo "== inspect referenced worker in testWebAssembly.js for error handling patterns =="
FILE2="test-app/app/src/main/assets/app/tests/testWebAssembly.js"
if [ -f "$FILE2" ]; then
  awk 'NR>=40 && NR<=120 { printf "%6d %s\n", NR, $0 }' "$FILE2"
else
  echo "Missing $FILE2"
fi

Repository: NativeScript/android

Length of output: 2324


🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a -u -i "EvalWorker.js" . || true

Repository: NativeScript/android

Length of output: 46


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate EvalWorker.js by filename =="
fd -a -u -i "EvalWorker.js" . || true

echo "== locate any file containing 'EvalWorker' =="
rg -n "EvalWorker" -S . | head -n 50 || true

echo "== search for worker error handling patterns in assets/app/tests =="
rg -n "onerror|onmessageerror|worker\\.onerror|addEventListener\\(['\"]error['\"]\\)" \
  -S test-app/app/src/main/assets/app/tests | head -n 200 || true

echo "== inspect testWebAssembly.js around worker setup =="
FILE2="test-app/app/src/main/assets/app/tests/testWebAssembly.js"
if [ -f "$FILE2" ]; then
  awk 'NR>=45 && NR<=110 { printf "%6d %s\n", NR, $0 }' "$FILE2"
fi

Repository: NativeScript/android

Length of output: 1752


🏁 Script executed:

# no-op placeholder

Repository: NativeScript/android

Length of output: 46


Add a worker error/timeout path so this spec can’t hang.

The async test ("should share a SharedArrayBuffer's memory with a worker and a Java buffer at once") calls done() only from worker.onmessage; there’s no worker.onerror/onmessageerror handler (and EvalWorker.js isn’t present to inspect any built-in handling). If worker startup or postMessage fails before onmessage, the spec will wait for the harness timeout instead of failing immediately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js` around
lines 62 - 74, The test can hang because it only calls done() from
worker.onmessage; add a worker error/timeout path: attach worker.onerror and
worker.onmessageerror handlers that call done.fail(...) (or fail the test) and
terminate the worker, and implement a short watchdog timer (clear it on success)
that fails the spec and terminates the worker if no message/error arrives within
e.g. a few seconds; update the block that creates
Worker("../shared/Workers/EvalWorker.js"), worker.postMessage(...), and
worker.onmessage to also set up these handlers and the timeout so the spec
always completes on error or timeout.

Comment on lines +995 to +1052
static int GetWorkerThreadPriority(Isolate *isolate, Local<Context> context,
const v8::FunctionCallbackInfo<v8::Value> &args) {
const int defaultPriority = 10; // android.os.Process.THREAD_PRIORITY_BACKGROUND

if (args.Length() < 2 || !args[1]->IsObject()) {
return defaultPriority;
}

auto options = args[1].As<Object>();
Local<Value> value;
if (!options->Get(context, ArgConverter::ConvertToV8String(isolate, "androidPriority"))
.ToLocal(&value) ||
value->IsNullOrUndefined()) {
return defaultPriority;
}

if (value->IsNumber()) {
int priority = value->Int32Value(context).FromMaybe(defaultPriority);
if (priority < -20) {
priority = -20;
} else if (priority > 19) {
priority = 19;
}
return priority;
}

if (value->IsString()) {
auto name = ArgConverter::ConvertToString(value.As<String>());
if (name == "lowest") {
return 19;
} else if (name == "background") {
return 10;
} else if (name == "lessFavorable") {
return 1;
} else if (name == "default") {
return 0;
} else if (name == "moreFavorable") {
return -1;
} else if (name == "foreground") {
return -2;
} else if (name == "display") {
return -4;
} else if (name == "urgentDisplay") {
return -8;
} else if (name == "video") {
return -10;
} else if (name == "audio") {
return -16;
} else if (name == "urgentAudio") {
return -19;
}
}

throw NativeScriptException(
"Invalid value for the Worker 'androidPriority' option. Expected one of: "
"'lowest', 'background', 'lessFavorable', 'default', 'moreFavorable', "
"'foreground', 'display', 'urgentDisplay', 'video', 'audio', 'urgentAudio' "
"or a number between -20 and 19.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Accept the documented THREAD_PRIORITY_* names too.

This parser only recognizes custom aliases like background and urgentDisplay, but the new worker option is described as accepting Android THREAD_PRIORITY_* names. Callers passing THREAD_PRIORITY_BACKGROUND will currently get a runtime exception instead of the advertised behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/CallbackHandlers.cpp` around lines 995 - 1052,
GetWorkerThreadPriority currently only recognizes custom lowercase aliases for
options.androidPriority and throws for Android constants; update the
string-handling branch in GetWorkerThreadPriority to accept documented
THREAD_PRIORITY_* names (e.g., "THREAD_PRIORITY_BACKGROUND",
"THREAD_PRIORITY_FOREGROUND", etc.) by normalizing the input (trim, uppercase)
and stripping an optional "THREAD_PRIORITY_" prefix before comparing against the
existing aliases
("LOWEST","BACKGROUND","LESSFAVORABLE","DEFAULT","MOREFAVORABLE","FOREGROUND","DISPLAY","URGENTDISPLAY","VIDEO","AUDIO","URGENTAUDIO");
map those normalized names to the same numeric values the existing lowercase
checks return so callers passing either form succeed.

Comment thread test-app/runtime/src/main/cpp/ConcurrentQueue.cpp Outdated
Comment thread test-app/runtime/src/main/cpp/ConcurrentQueue.cpp Outdated
Comment on lines +29 to +33
Maybe<bool> WriteHostObject(Isolate* isolate, Local<Object> object) override {
// Host objects (e.g. Java proxies) carry no transferable native state;
// they are recreated as plain objects on the receiving side.
return Just(true);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In V8's ValueSerializer::Delegate API, is it valid to return Just(true)fromWriteHostObject without encoding host-object state, and what is the expected behavior for unsupported host objects during structured cloning?

💡 Result:

In V8's ValueSerializer::Delegate API, returning Just(true) from WriteHostObject without encoding any additional data is technically valid in terms of type signature, but it is incorrect behavior because it fails to represent the host object in the serialized output [1][2][3]. The WriteHostObject method is responsible for serializing the host object's state [1][3]. When you return Just(true), the ValueSerializer assumes that the host object has been successfully serialized into the buffer (typically via the serializer's own writing methods, such as WriteUint32 or WriteRawBytes) [2][4]. If you return Just(true) without having written any bytes for that object, the resulting serialized data will be corrupted or incomplete, leading to issues during deserialization [2][3]. Regarding unsupported host objects, the expected behavior is for the delegate to signal an error [1][3]. If an embedder cannot serialize a specific host object, it must: 1. Throw a suitable exception (typically a DataCloneError) within the Isolate [1][3][5]. 2. Return Nothing to indicate failure to the serializer [1][2][3]. If a delegate is not provided or if the delegate does not handle the object, the V8 ValueSerializer will throw a DataCloneError automatically [6][2][7]. Therefore, for unsupported objects, you should explicitly trigger this error and return Nothing to ensure the structured clone process terminates correctly [1][3][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="test-app/runtime/src/main/cpp/WorkerMessage.cpp"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "=== Locations: WriteHostObject / ReadHostObject ==="
rg -n "WriteHostObject|ReadHostObject" "$FILE" || true

echo
echo "=== Context around WriteHostObject (lines ~1-120) ==="
nl -ba "$FILE" | sed -n '1,160p'

echo
echo "=== Context around ReadHostObject (lines ~1-220) ==="
nl -ba "$FILE" | sed -n '1,260p'

Repository: NativeScript/android

Length of output: 389


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="test-app/runtime/src/main/cpp/WorkerMessage.cpp"

echo "== File stats =="
wc -l "$FILE"

echo
echo "== WriteHostObject context (approx. 1-110) =="
cat -n "$FILE" | sed -n '1,110p'

echo
echo "== ReadHostObject context (approx. 55-140) =="
cat -n "$FILE" | sed -n '55,160p'

Repository: NativeScript/android

Length of output: 9048


🌐 Web query:

V8 ValueSerializer::Delegate::WriteHostObject return Just(true) semantics; does it require writing clone data via serializer delegate? what triggers DataCloneError for unsupported host objects?

💡 Result:

In the V8 ValueSerializer::Delegate API, the WriteHostObject method is invoked by the serializer when it encounters an object that it cannot serialize using its default mechanisms (such as a JS object with internal embedder fields or specific types configured via SetTreatArrayBufferViewsAsHostObjects) [1][2][3]. Semantics of WriteHostObject return values: * Returning Just(true) signifies that the host has successfully handled the serialization of the object [1][2]. When returning Just(true), the embedder is expected to have written the necessary representation of that object into the serializer's buffer using the provided writing methods (e.g., WriteRawBytes, WriteUint32, WriteDouble) [4][3]. * Returning Nothing() (typically accompanied by throwing a JavaScript exception in the isolate) indicates that the host failed to serialize the object, or that the object is not supported [5][4]. Regarding DataCloneError: * A DataCloneError is triggered when the ValueSerializer encounters an object it cannot serialize and either no Delegate is provided, or the Delegate fails to handle the object [1][2]. * If no Delegate is provided, V8 automatically throws a DataCloneError when it encounters a host object (e.g., an object with internal fields) [1][6]. * If a Delegate is provided, the responsibility shifts to the implementation of WriteHostObject [1]. If the embedder determines that a specific object is not serializable, it must explicitly throw a DataCloneError (often using isolate->Throw(...)) and then return Nothing() to stop the serialization process [5][4][7]. The ThrowDataCloneError method in the Delegate is also available for the embedder to report such errors in a standardized way [8]. In summary, Just(true) requires the embedder to manually write the object's serialized data to the buffer [3], whereas the DataCloneError is the standard mechanism to report that an object is unsupported or cannot be serialized during the traversal [5][1][9].

Citations:


🌐 Web query:

V8 ValueDeserializer::Delegate::ReadHostObject expected behavior: called only when WriteHostObject returns true and writes host-object data; what does it receive/expect?

💡 Result:

In the V8 engine, the ReadHostObject method within v8::ValueDeserializer::Delegate is intended for the embedder to reconstruct "host objects" that were previously serialized via the corresponding WriteHostObject method [1][2]. Expected Behavior: When the V8 deserializer encounters a serialization tag corresponding to a host object (specifically kHostObject) in the serialized data, it calls the embedder's ReadHostObject implementation to handle the reconstruction [3][4]. What it receives and expects: 1. Arguments: It receives an Isolate* as the current execution context [1][2]. 2. Input Data: The method does not receive the raw data directly as an argument; instead, the embedder is expected to read the necessary data from the deserializer's internal buffer [5][2]. The embedder uses the v8::ValueDeserializer instance's reading methods—such as ReadUint32, ReadUint64, ReadDouble, or ReadRawBytes—to consume the specific data it previously wrote during the serialization process [5][2][6]. 3. Return Value: It is expected to return a MaybeLocal [1][2]. If the host object is successfully reconstructed, it returns the JavaScript object wrapper; if reconstruction fails, it should throw an appropriate exception and return an empty MaybeLocal (Nothing) [1][2]. In summary, ReadHostObject is the mechanism for custom deserialization logic, where the embedder is responsible for manually reading back the exact byte-stream or format they defined during the serialization phase [3][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List related files
fd -i "WorkerMessage.*" test-app/runtime/src/main/cpp -t f || true
fd -i "Message.*Worker.*" test-app/runtime/src/main/cpp -t f || true

echo "---- WorkerMessage.h ----"
FILE_H="test-app/runtime/src/main/cpp/WorkerMessage.h"
if [ -f "$FILE_H" ]; then
  wc -l "$FILE_H"
  cat -n "$FILE_H" | sed -n '1,200p'
fi

echo "---- Search for Serialize/Deserialize usage ----"
rg -n "Message::Serialize\\(|Message::Deserialize\\(|WorkerMessage::" test-app/runtime/src/main/cpp || true

echo "---- Search for host-object handling / structured clone / DataCloneError ----"
rg -n "HostObject|DataCloneError|WriteHostObject|ReadHostObject|structured clone|Transfer" test-app/runtime/src/main/cpp || true

echo "---- Show entire WorkerMessage.cpp for full context (<=160 lines) ----"
cat -n test-app/runtime/src/main/cpp/WorkerMessage.cpp

Repository: NativeScript/android

Length of output: 14795


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="test-app/runtime/src/main/cpp/include/v8-value-serializer.h"
wc -l "$FILE"
echo "---- first 260 lines ----"
cat -n "$FILE" | sed -n '1,260p'

Repository: NativeScript/android

Length of output: 11036


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ROOT="test-app/runtime/src/main/cpp"
rg -n "Message::Serialize\\(" "$ROOT" || true
rg -n "Message::Deserialize\\(" "$ROOT" || true
rg -n "DataCloneError" "$ROOT/CallbackHandlers.cpp" "$ROOT" || true

echo "---- CallbackHandlers.cpp around first DataCloneError occurrence ----"
# pick a window near the earlier rg line numbers we saw: 1221/1273
sed -n '1190,1310p' "$ROOT/CallbackHandlers.cpp"

Repository: NativeScript/android

Length of output: 6611


Don’t silently accept host objects during worker structured cloning

SerializerDelegate::WriteHostObject (29-33) always returns Just(true) without writing any host-object data, and DeserializerDelegate::ReadHostObject (68-72) always materializes a fresh {}. This causes unsupported host objects to “clone” successfully while dropping identity/native state instead of reporting a DataCloneError, which conflicts with the V8 delegate contract (return Nothing<bool>() after throwing when host objects aren’t serializable).

Safer default
     Maybe<bool> WriteHostObject(Isolate* isolate, Local<Object> object) override {
-        // Host objects (e.g. Java proxies) carry no transferable native state;
-        // they are recreated as plain objects on the receiving side.
-        return Just(true);
+        ThrowDataCloneException(
+                context_,
+                ArgConverter::ConvertToV8String(
+                        isolate, "Cannot clone host objects across workers"));
+        return Nothing<bool>();
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/WorkerMessage.cpp` around lines 29 - 33, The
current WriteHostObject (SerializerDelegate::WriteHostObject) and
DeserializerDelegate::ReadHostObject silently accept host objects and
materialize empty objects; instead, detect unsupported host objects and surface
a DataCloneError via the V8 isolate and return Nothing<bool>() from
WriteHostObject (and return Nothing/throw in ReadHostObject) per the V8 delegate
contract. Update WriteHostObject to not return Just(true) when the object is not
serializable: call the isolate exception throw helper for a DataCloneError (or
v8 equivalent) and return Nothing<bool>(). Likewise update ReadHostObject to
throw the same DataCloneError and return the appropriate Nothing result rather
than creating a blank object. Ensure you reference the existing
SerializerDelegate::WriteHostObject and DeserializerDelegate::ReadHostObject
symbols when making these changes.

Comment on lines +54 to +69
void WorkerWrapper::PostMessageToParent(std::shared_ptr<worker::Message> message) {
if (isTerminating_) {
return;
}

auto parentTasks = parentTasks_.lock();
if (parentTasks == nullptr) {
// the parent runtime is gone (e.g. a parent worker that shut down)
return;
}

int workerId = workerId_;
parentTasks->Post([workerId, message]() {
WorkerWrapper::FireMessageOnParentWorkerObject(workerId, message);
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Block worker→parent posts once close() has started.

Close() marks the worker as closing, but PostMessageToParent() only rejects isTerminating_. A worker can still do close(); postMessage(...) in the same turn and deliver a message after shutdown has begun, which contradicts the drop-pending-messages behavior described here.

Also applies to: 92-102

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/WorkerWrapper.cpp` around lines 54 - 69,
PostMessageToParent currently only checks isTerminating_, so messages can still
be posted after Close() begins; update WorkerWrapper::PostMessageToParent to
also check the "closing" state (the flag set by Close(), e.g., isClosing_ or
whatever Close() toggles) and return early if either isClosing_ or
isTerminating_ is true, and apply the same check to the other
PostMessageToParent overload/duplicate referenced (lines 92-102) so both paths
drop pending messages once Close() has started.

Comment on lines +274 to +299
if (hasOnError) {
auto errEvent = Object::New(isolate);
errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "message"),
ArgConverter::ConvertToV8String(isolate, message));
errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "stackTrace"),
ArgConverter::ConvertToV8String(isolate, stackTrace));
errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "filename"),
ArgConverter::ConvertToV8String(isolate, filename));
errEvent->Set(context, ArgConverter::ConvertToV8String(isolate, "lineno"),
Number::New(isolate, lineno));

Local<Value> args[] = {errEvent};

// If the handler returns a truthy value the exception is handled
// and must not be raised to application level
Local<Value> result;
callback.As<Function>()->Call(context, Undefined(isolate), 1, args).ToLocal(&result);
if (!result.IsEmpty() && result->BooleanValue(isolate)) {
return;
}
}

DEBUG_WRITE(
"Unhandled exception in '%s' thread. file: %s, line %d, message: %s\nStackTrace: %s",
threadName.c_str(), filename.c_str(), lineno, message.c_str(),
stackTrace.c_str());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unhandled worker errors are only logged, not surfaced.

When the parent worker.onerror is missing or returns falsy, this path falls through to DEBUG_WRITE(...) and exits. That silently swallows uncaught worker failures instead of raising them on the parent runtime/application.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/cpp/WorkerWrapper.cpp` around lines 274 - 299, The
code currently stops at DEBUG_WRITE when no onerror handler exists or it returns
falsy, which silently swallows the worker failure; update the path after the
callback check in WorkerWrapper.cpp (symbols: hasOnError, errEvent, callback,
DEBUG_WRITE) to surface the error to the parent runtime instead of only logging:
build a V8 exception/value from message/stackTrace/filename/lineno (e.g.
Exception::Error or a proper Error object) and either throw it into the current
isolate (isolate->ThrowException(...)) or invoke the parent error propagation
mechanism so the application receives the uncaught worker error; keep the
existing DEBUG_WRITE but ensure you call the chosen propagation (throw or host
error dispatch) when result is empty or falsy.

Comment thread test-app/runtime/src/main/cpp/WorkerWrapper.cpp
Comment on lines +488 to +491
public static Runtime initializeRuntimeWithConfiguration(StaticConfiguration config) {
staticConfiguration = config;
WorkThreadScheduler mainThreadScheduler = new WorkThreadScheduler(new Handler(Looper.myLooper()));
DynamicConfiguration dynamicConfiguration = new DynamicConfiguration(0, mainThreadScheduler, null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bind the main runtime to the real main looper.

Line 490 builds the scheduler from Looper.myLooper() even though this entry point is documented as main-thread only. If a host app ever calls initializeRuntimeWithConfiguration(...) from a background looper thread, the runtime gets pinned to that looper while getMainThreadHandler() and isNotOnMainThread() still target Looper.getMainLooper(), which splits JS work across two different threads. Fail fast on non-main callers and create the handler from Looper.getMainLooper().

Proposed fix
 public static Runtime initializeRuntimeWithConfiguration(StaticConfiguration config) {
+    if (Looper.myLooper() != Looper.getMainLooper()) {
+        throw new IllegalStateException("initializeRuntimeWithConfiguration must run on the main thread");
+    }
     staticConfiguration = config;
-    WorkThreadScheduler mainThreadScheduler = new WorkThreadScheduler(new Handler(Looper.myLooper()));
+    WorkThreadScheduler mainThreadScheduler = new WorkThreadScheduler(new Handler(Looper.getMainLooper()));
     DynamicConfiguration dynamicConfiguration = new DynamicConfiguration(0, mainThreadScheduler, null);
     Runtime runtime = initRuntime(dynamicConfiguration);
     return runtime;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public static Runtime initializeRuntimeWithConfiguration(StaticConfiguration config) {
staticConfiguration = config;
WorkThreadScheduler mainThreadScheduler = new WorkThreadScheduler(new Handler(Looper.myLooper()));
DynamicConfiguration dynamicConfiguration = new DynamicConfiguration(0, mainThreadScheduler, null);
public static Runtime initializeRuntimeWithConfiguration(StaticConfiguration config) {
if (Looper.myLooper() != Looper.getMainLooper()) {
throw new IllegalStateException("initializeRuntimeWithConfiguration must run on the main thread");
}
staticConfiguration = config;
WorkThreadScheduler mainThreadScheduler = new WorkThreadScheduler(new Handler(Looper.getMainLooper()));
DynamicConfiguration dynamicConfiguration = new DynamicConfiguration(0, mainThreadScheduler, null);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/java/com/tns/Runtime.java` around lines 488 - 491,
The initializeRuntimeWithConfiguration method currently constructs
WorkThreadScheduler with Looper.myLooper(), which allows callers on background
loopers to bind the runtime to the wrong looper; change it to verify the caller
is on the real main looper and use Looper.getMainLooper() to build the
WorkThreadScheduler. Specifically, in initializeRuntimeWithConfiguration check
Thread/Looper identity and throw an IllegalStateException (or similar) if
Looper.myLooper() != Looper.getMainLooper(), then create new
WorkThreadScheduler(new Handler(Looper.getMainLooper())) so
getMainThreadHandler() and isNotOnMainThread() consistently reference the true
main looper.

Comment thread test-app/runtime/src/main/java/com/tns/Runtime.java
- WorkerWrapper: dispose the isolate via runtime_->GetIsolate() during
  shutdown instead of the workerIsolate_ atomic, which is still null if
  the bootstrap fails between initWorkerRuntime and the isolate publish
  (v8::Locker on a null isolate would crash the worker thread)
- ConcurrentQueue: hold the lifecycle lock across enqueue + wakeup so a
  Push racing Terminate can no longer strand a message (and its backing
  stores) in a queue nothing will ever drain; Terminate also clears
  anything a racer already enqueued
- ConcurrentQueue/LooperTasks: check eventfd()/ALooper_addFd() results
  and unwind on failure instead of registering a broken wakeup path
- WorkerMessage: Malloc<T> now allocates sizeof(T) * n - n is an element
  count (latent footgun; only instantiated with char today)
- Runtime.initRuntime: roll back runtimeCache/currentRuntime/GcListener
  registrations when init() or ts_helpers.js fails, so a failed
  bootstrap doesn't leave a stale half-initialized runtime reachable
- tests: replace the Objects.requireNonNull zero-copy probe with a typed
  ByteBufferHolder fixture - generic erasure made the returned proxy
  Object-typed, so no ByteBuffer methods were reachable; also assert
  Java-side writes are visible through the SharedArrayBuffer and fail
  fast via worker.onerror instead of hanging to the jasmine timeout

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test-app/runtime/src/main/java/com/tns/Runtime.java (1)

242-246: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Constructor-time publication still bypasses this rollback.

The new catch only runs after new Runtime(...) returns, but the constructor already stores this in currentRuntime and runtimeCache at Lines 242-245 before GcListener.getInstance(...) finishes on Line 246. If that constructor step throws, the thread-local and cache still retain a half-built runtime, so retries on the same thread can fail with the existing-runtime guard.

Proposed fix
-                currentRuntime.set(this);
-
-                runtimeCache.put(this.runtimeId, this);
-
                 gcListener = GcListener.getInstance(config.appConfig.getGcThrottleTime(), config.appConfig.getMemoryCheckInterval(), config.appConfig.getFreeMemoryRatio());
                 // capture static configuration to allow native lookups when currentRuntime is unavailable
                 staticConfiguration = config;
+
+                currentRuntime.set(this);
+                runtimeCache.put(this.runtimeId, this);

Also applies to: 558-569

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-app/runtime/src/main/java/com/tns/Runtime.java` around lines 242 - 246,
The constructor publishes a partially-initialized Runtime (via
currentRuntime.set(this) and runtimeCache.put(this.runtimeId, this)) before
calling GcListener.getInstance(...) so if that call (or anything later in the
ctor) throws the thread-local/cache retain a bad instance; fix by deferring
publication until after all initialization that can throw completes (move
currentRuntime.set(this) and runtimeCache.put(this.runtimeId, this) to after
successful GcListener.getInstance(...) and any other throwing init), or
alternatively wrap the constructor body in a try/catch that on any exception
clears currentRuntime and removes runtimeCache entry for this.runtimeId before
rethrowing, ensuring no half-built Runtime remains accessible.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test-app/runtime/src/main/java/com/tns/Runtime.java`:
- Around line 242-246: The constructor publishes a partially-initialized Runtime
(via currentRuntime.set(this) and runtimeCache.put(this.runtimeId, this)) before
calling GcListener.getInstance(...) so if that call (or anything later in the
ctor) throws the thread-local/cache retain a bad instance; fix by deferring
publication until after all initialization that can throw completes (move
currentRuntime.set(this) and runtimeCache.put(this.runtimeId, this) to after
successful GcListener.getInstance(...) and any other throwing init), or
alternatively wrap the constructor body in a try/catch that on any exception
clears currentRuntime and removes runtimeCache entry for this.runtimeId before
rethrowing, ensuring no half-built Runtime remains accessible.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0f0c76b0-fe99-48aa-b3bb-6b4a00d2a6b8

📥 Commits

Reviewing files that changed from the base of the PR and between 8fb76ca and 6b12678.

📒 Files selected for processing (9)
  • test-app/app/src/main/assets/app/shared
  • test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js
  • test-app/app/src/main/java/com/tns/tests/ByteBufferHolder.java
  • test-app/runtime/src/main/cpp/ConcurrentQueue.cpp
  • test-app/runtime/src/main/cpp/ConcurrentQueue.h
  • test-app/runtime/src/main/cpp/LooperTasks.cpp
  • test-app/runtime/src/main/cpp/WorkerMessage.h
  • test-app/runtime/src/main/cpp/WorkerWrapper.cpp
  • test-app/runtime/src/main/java/com/tns/Runtime.java
💤 Files with no reviewable changes (1)
  • test-app/runtime/src/main/cpp/ConcurrentQueue.h
🚧 Files skipped from review as they are similar to previous changes (4)
  • test-app/runtime/src/main/cpp/WorkerMessage.h
  • test-app/app/src/main/assets/app/tests/shared-array-buffer-test.js
  • test-app/runtime/src/main/cpp/LooperTasks.cpp
  • test-app/runtime/src/main/cpp/WorkerWrapper.cpp

@NathanWalker NathanWalker merged commit a84d3c7 into main Jun 12, 2026
8 checks passed
@NathanWalker NathanWalker deleted the feat/worker-debug branch June 12, 2026 17:58
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