Skip to content

feat(inspector): attach Chrome DevTools to Web Worker isolates - #1973

Merged
NathanWalker merged 2 commits into
mainfrom
feat/worker-inspector
Jun 16, 2026
Merged

feat(inspector): attach Chrome DevTools to Web Worker isolates#1973
NathanWalker merged 2 commits into
mainfrom
feat/worker-inspector

Conversation

@edusperoni

@edusperoni edusperoni commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Workers were invisible to the debugger: the inspector only served the main isolate and even dropped worker console output on the floor. DevTools discovers extra isolates through the Target domain with flat-session multiplexing, so implement that surface in the runtime:

  • Add WorkerInspectorClient: a per-worker V8Inspector + session that lives entirely on the worker's thread. Incoming CDP messages queue through an eventfd on the worker's ALooper (the same mechanism as the worker message inbox); while paused at a breakpoint a nested loop on the worker thread pumps the same queue without re-entering the looper, so postMessage deliveries stay queued during a pause, matching Chrome's semantics.
  • Turn JsV8InspectorClient into the root session + router: handle Target.setAutoAttach natively on the socket thread (announce workers via Target.attachedToTarget, detach on death), route messages carrying a top-level sessionId to the worker's thread directly from the socket thread, and make the frontend sender thread-safe. Routing off the socket thread means a worker stays debuggable while the main isolate is paused, and vice versa.
  • Debugger.pause for a busy worker uses RequestInterrupt keyed by workerId, so a late-firing interrupt after worker death is a no-op; it is skipped while that worker is already paused, avoiding a spurious re-pause after resume (same guard as the main isolate).
  • Serve Network.loadNetworkResource and IO.read/IO.close on the socket thread for any session (sessionId echoed), so worker source maps load too, and apply the nsruntime:// sourceMapURL rewrite to worker Debugger.scriptParsed events.
  • Route worker console.* to the worker's own inspector and deliver through V8ConsoleMessageStorage::addMessage instead of the live-only runtime agent path: messages logged before the frontend attaches are stored and replayed as console history. Applies to the main isolate as well.
  • Name execution contexts ("main" / worker script url) so the DevTools console context selector rows are labeled and selectable.
  • Worker lifecycle: the inspector is created on the worker thread before the script runs (debug builds only), terminate() kicks a paused worker out of its nested pause loop, teardown unregisters the target before the isolate is disposed, and frontend reconnects reset worker sessions (ordered before the main-isolate Locker so workers recover even if the main isolate is paused).
  • Make isConnected_ atomic: worker threads now read it while the main thread writes the adjacent bitfield flags.

waitForDebuggerOnStart (pause new workers before their first line) is left as future work; workers announce waitingForDebugger: false.

Ports NativeScript/ios#386 to Android.

Summary by CodeRabbit

  • New Features
    • Added worker-side V8 inspector support with DevTools-ready session/target routing.
    • Worker console output is now forwarded into the inspector for visibility.
  • Bug Fixes
    • Improved socket-thread message handling to send only when a non-empty response is produced by the fast path.
    • Strengthened thread-safety for inspector connections and singleton initialization.
    • Refined pause/reset behavior and worker session acknowledgment handling.
  • Chores
    • Enabled additional inspector sources for debug builds.
Workers were invisible to the debugger: the inspector only served the
main isolate and even dropped worker console output on the floor.
DevTools discovers extra isolates through the Target domain with
flat-session multiplexing, so implement that surface in the runtime:

- Add WorkerInspectorClient: a per-worker V8Inspector + session that
  lives entirely on the worker's thread. Incoming CDP messages queue
  through an eventfd on the worker's ALooper (the same mechanism as the
  worker message inbox); while paused at a breakpoint a nested loop on
  the worker thread pumps the same queue without re-entering the
  looper, so postMessage deliveries stay queued during a pause,
  matching Chrome's semantics.
- Turn JsV8InspectorClient into the root session + router: handle
  Target.setAutoAttach natively on the socket thread (announce workers
  via Target.attachedToTarget, detach on death), route messages
  carrying a top-level sessionId to the worker's thread directly from
  the socket thread, and make the frontend sender thread-safe. Routing
  off the socket thread means a worker stays debuggable while the main
  isolate is paused, and vice versa.
- Debugger.pause for a busy worker uses RequestInterrupt keyed by
  workerId, so a late-firing interrupt after worker death is a no-op;
  it is skipped while that worker is already paused, avoiding a
  spurious re-pause after resume (same guard as the main isolate).
- Serve Network.loadNetworkResource and IO.read/IO.close on the socket
  thread for any session (sessionId echoed), so worker source maps
  load too, and apply the nsruntime:// sourceMapURL rewrite to worker
  Debugger.scriptParsed events.
- Route worker console.* to the worker's own inspector and deliver
  through V8ConsoleMessageStorage::addMessage instead of the live-only
  runtime agent path: messages logged before the frontend attaches are
  stored and replayed as console history. Applies to the main isolate
  as well.
- Name execution contexts ("main" / worker script url) so the DevTools
  console context selector rows are labeled and selectable.
- Worker lifecycle: the inspector is created on the worker thread
  before the script runs (debug builds only), terminate() kicks a
  paused worker out of its nested pause loop, teardown unregisters the
  target before the isolate is disposed, and frontend reconnects reset
  worker sessions (ordered before the main-isolate Locker so workers
  recover even if the main isolate is paused).
- Make isConnected_ atomic: worker threads now read it while the main
  thread writes the adjacent bitfield flags.

waitForDebuggerOnStart (pause new workers before their first line) is
left as future work; workers announce waitingForDebugger: false.

Ports NativeScript/ios#386 to Android.
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

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: b324d733-93fa-46b9-a0f7-bfb0025f9246

📥 Commits

Reviewing files that changed from the base of the PR and between 2656b65 and 13ae257.

📒 Files selected for processing (1)
  • test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp

📝 Walkthrough

Walkthrough

This PR adds worker thread debugging support to the Android V8 inspector. The socket message handler contract shifts from string return to boolean-with-response, worker targets are registered and routed via session identifiers, a new per-worker inspector client integrates with the worker's event loop, and inspector lifecycle is wired into worker bootstrap/teardown with console message forwarding.

Changes

Worker Thread Debugging Infrastructure

Layer / File(s) Summary
Socket message handler contract and Java bridge
test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java, test-app/runtime/src/main/cpp/com_tns_AndroidJsV8Inspector.cpp, test-app/runtime/CMakeLists.txt
Java fast-path refined to send only non-empty responses. JNI bridge and CMakeLists updated to use new bool handleMessageOnSocketThread(message, response) contract.
Main inspector header contracts and atomicity
test-app/runtime/src/main/cpp/JsV8InspectorClient.h, test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (includes, header updates)
Singleton changed to std::atomic<JsV8InspectorClient*>, includes added for worker routing support, WorkerTarget struct and tracking map introduced, connection state upgraded to std::atomic<bool>.
Socket-thread worker routing and command handling
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (message handler, routing helpers)
Socket-thread handler parses JSON and routes by sessionId, handles Network.loadNetworkResource/IO.read/IO.close/Target.* commands with session-aware replies, implements Debugger.pause interrupt logic aware of nested pause loops, and provides worker target registration/announcement helpers.
Connection disconnect refactor and locking
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (disconnect)
Worker sessions reset first, JNI cleanup synchronized under connectionMutex_, and inspector session recreated.
Frontend send safety and callback updates
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (SendToFrontend, callbacks)
New SendToFrontend snapshots JNI refs under connectionMutex_, rewrites source map URLs, and clears exceptions on failure. Singleton access and event/console callbacks refactored for atomic semantics and connection state checking. Console routing separates workers via WorkerWrapper and main-thread via V8 inspector message storage.
Worker inspector client header contract
test-app/runtime/src/main/cpp/WorkerInspectorClient.h
Declares WorkerInspectorClient implementing V8InspectorClient and V8Inspector::Channel with cross-thread messaging, pause control, termination, console logging, and session reset support.
Worker inspector client implementation
test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp
Implements per-worker inspector with eventfd-backed looper integration, message queueing/draining, protocol dispatch with session reset coordination, pause-loop message pump with interruption scheduling, flat-session protocol wrapping with sessionId prefix, and V8 stack-trace-based console message storage.
WorkerWrapper lifecycle wiring and console forwarding
test-app/runtime/src/main/cpp/WorkerWrapper.h, test-app/runtime/src/main/cpp/WorkerWrapper.cpp
Debug-only inspector creation during bootstrap with file-based URL, termination notification under lock to release paused workers, and destruction before runtime teardown. Header exposes public console-log forwarding and private lifecycle methods with synchronization primitives.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • NativeScript/android#1969: Both PRs touch the socket-thread inspector command handling, with #1969 adding Network.loadNetworkResource/IO.read/IO.close server-side support and this PR refactoring the message handler contract to bool-plus-out-parameter.
  • NativeScript/android#1967: Both PRs refine the handleMessageOnSocketThread fast-path behavior in AndroidJsV8Inspector and the native message routing semantics.

Suggested reviewers

  • rigor789
  • NathanWalker

🐰 Worker threads now share the debugging dream,
DevTools sees each isolate's shimmering stream,
Flat sessions, pause loops, and messages flow free,
Console logs bloom where the workers decree!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.81% 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 'feat(inspector): attach Chrome DevTools to Web Worker isolates' directly and clearly describes the main feature being implemented—adding DevTools support for Web Workers—matching the primary objective of 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.

@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: 2

🧹 Nitpick comments (1)
test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp (1)

514-524: 🩺 Stability & Availability | 🏗️ Heavy lift

Avoid socket writes while holding workerTargetsMutex_.

Line 514+ / Line 542+ / Line 571+ / Line 585+ call SendToFrontend(...) while the registry mutex is held. If socket send blocks, worker register/unregister/routing can stall behind that lock. Snapshot payloads under lock, then send after unlocking.

Also applies to: 542-559, 571-577, 585-605

🤖 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/JsV8InspectorClient.cpp` around lines 514 -
524, You're holding workerTargetsMutex_ while performing socket writes via
SendToFrontend/JsonDump (e.g., in the branch handling missing sessionId and
other branches that currently call SendToFrontend while locked), which can block
other registry operations; instead, inside the critical section (while holding
workerTargetsMutex_) construct and copy the JSON payload/string you intend to
send and then release the lock, and only after unlocking call
SendToFrontend(JsonDump(...)) or SendToFrontend(copiedPayload). Apply this
pattern for all occurrences that call SendToFrontend while workerTargetsMutex_
is held (search for SendToFrontend usages near workerTargets_ handling: the
missing-session branch, and the other locations noted in the comment).
🤖 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/runtime/src/main/cpp/JsV8InspectorClient.cpp`:
- Around line 814-821: GetInstance currently does an atomic load->new->store
which can race and leak if two threads concurrently see null; change
JsV8InspectorClient::GetInstance to perform a race-free singleton init by using
either std::call_once with a std::once_flag or an atomic compare_exchange loop
on the static instance so only one caller constructs the object and others reuse
it; keep the creation using Runtime::GetRuntime(0)->GetIsolate() and ensure
GetInstanceIfCreated remains unchanged, and verify callers like
Java_com_tns_AndroidJsV8Inspector_handleMessageOnSocketThread use the fixed
GetInstance to avoid double construction.

In `@test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp`:
- Around line 205-239: The pauseTerminated_ flag is subject to a data race
because it is written from NotifyTerminating() (called from another thread)
while being read/modified in runMessageLoopOnPause() and
quitMessageLoopOnPause(); change the pauseTerminated_ member in
WorkerInspectorClient to std::atomic<bool> and update all accesses to use atomic
operations with appropriate memory ordering (e.g., load(memory_order_acquire)
when reading in runMessageLoopOnPause and store(memory_order_release) when
setting in NotifyTerminating and quitMessageLoopOnPause) so reads/writes are
synchronized across threads.

---

Nitpick comments:
In `@test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp`:
- Around line 514-524: You're holding workerTargetsMutex_ while performing
socket writes via SendToFrontend/JsonDump (e.g., in the branch handling missing
sessionId and other branches that currently call SendToFrontend while locked),
which can block other registry operations; instead, inside the critical section
(while holding workerTargetsMutex_) construct and copy the JSON payload/string
you intend to send and then release the lock, and only after unlocking call
SendToFrontend(JsonDump(...)) or SendToFrontend(copiedPayload). Apply this
pattern for all occurrences that call SendToFrontend while workerTargetsMutex_
is held (search for SendToFrontend usages near workerTargets_ handling: the
missing-session branch, and the other locations noted in the comment).
🪄 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: a62cec75-6809-44d1-af66-cbcd3dff76e0

📥 Commits

Reviewing files that changed from the base of the PR and between a84d3c7 and 2656b65.

📒 Files selected for processing (9)
  • test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java
  • test-app/runtime/CMakeLists.txt
  • test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp
  • test-app/runtime/src/main/cpp/JsV8InspectorClient.h
  • test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp
  • test-app/runtime/src/main/cpp/WorkerInspectorClient.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_AndroidJsV8Inspector.cpp
Comment thread test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp
Comment on lines +205 to +239
void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
return;
}
runningPauseLoop_.store(true, std::memory_order_release);
pauseTerminated_ = false;

while (!pauseTerminated_ && !dying_) {
std::string message = this->PopMessage();
bool shouldWait = message.empty();
if (!shouldWait) {
this->DispatchOne(message);
}

while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) {
}

if (shouldWait && !pauseTerminated_ && !dying_) {
std::unique_lock<std::mutex> lock(messageArrivedMutex_);
messageArrived_.wait_for(lock, std::chrono::milliseconds(1));
}
}

runningPauseLoop_.store(false, std::memory_order_release);
}

void WorkerInspectorClient::quitMessageLoopOnPause() {
pauseTerminated_ = true;
}

void WorkerInspectorClient::NotifyTerminating() {
dying_ = true;
pauseTerminated_ = true;
messageArrived_.notify_all();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Data race on pauseTerminated_ flag.

pauseTerminated_ is written from a different thread in NotifyTerminating() (called via WorkerWrapper::Terminate() from the parent thread) while it may be read concurrently in runMessageLoopOnPause() on the worker thread. This needs to be an std::atomic<bool> with appropriate memory ordering.

🔒 Proposed fix: make pauseTerminated_ atomic

In the header, change:

- bool pauseTerminated_ = false;
+ std::atomic<bool> pauseTerminated_{false};

Then update usages with appropriate memory ordering:

 void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
     if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
         return;
     }
     runningPauseLoop_.store(true, std::memory_order_release);
-    pauseTerminated_ = false;
+    pauseTerminated_.store(false, std::memory_order_relaxed);
 
-    while (!pauseTerminated_ && !dying_) {
+    while (!pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
         // ...
-        if (shouldWait && !pauseTerminated_ && !dying_) {
+        if (shouldWait && !pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
             // ...
         }
     }
     // ...
 }
 
 void WorkerInspectorClient::quitMessageLoopOnPause() {
-    pauseTerminated_ = true;
+    pauseTerminated_.store(true, std::memory_order_release);
 }
 
 void WorkerInspectorClient::NotifyTerminating() {
     dying_ = true;
-    pauseTerminated_ = true;
+    pauseTerminated_.store(true, std::memory_order_release);
     messageArrived_.notify_all();
 }
📝 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
void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
return;
}
runningPauseLoop_.store(true, std::memory_order_release);
pauseTerminated_ = false;
while (!pauseTerminated_ && !dying_) {
std::string message = this->PopMessage();
bool shouldWait = message.empty();
if (!shouldWait) {
this->DispatchOne(message);
}
while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) {
}
if (shouldWait && !pauseTerminated_ && !dying_) {
std::unique_lock<std::mutex> lock(messageArrivedMutex_);
messageArrived_.wait_for(lock, std::chrono::milliseconds(1));
}
}
runningPauseLoop_.store(false, std::memory_order_release);
}
void WorkerInspectorClient::quitMessageLoopOnPause() {
pauseTerminated_ = true;
}
void WorkerInspectorClient::NotifyTerminating() {
dying_ = true;
pauseTerminated_ = true;
messageArrived_.notify_all();
}
void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) {
if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) {
return;
}
runningPauseLoop_.store(true, std::memory_order_release);
pauseTerminated_.store(false, std::memory_order_relaxed);
while (!pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
std::string message = this->PopMessage();
bool shouldWait = message.empty();
if (!shouldWait) {
this->DispatchOne(message);
}
while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) {
}
if (shouldWait && !pauseTerminated_.load(std::memory_order_acquire) && !dying_) {
std::unique_lock<std::mutex> lock(messageArrivedMutex_);
messageArrived_.wait_for(lock, std::chrono::milliseconds(1));
}
}
runningPauseLoop_.store(false, std::memory_order_release);
}
void WorkerInspectorClient::quitMessageLoopOnPause() {
pauseTerminated_.store(true, std::memory_order_release);
}
void WorkerInspectorClient::NotifyTerminating() {
dying_ = true;
pauseTerminated_.store(true, std::memory_order_release);
messageArrived_.notify_all();
}
🤖 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/WorkerInspectorClient.cpp` around lines 205 -
239, The pauseTerminated_ flag is subject to a data race because it is written
from NotifyTerminating() (called from another thread) while being read/modified
in runMessageLoopOnPause() and quitMessageLoopOnPause(); change the
pauseTerminated_ member in WorkerInspectorClient to std::atomic<bool> and update
all accesses to use atomic operations with appropriate memory ordering (e.g.,
load(memory_order_acquire) when reading in runMessageLoopOnPause and
store(memory_order_release) when setting in NotifyTerminating and
quitMessageLoopOnPause) so reads/writes are synchronized across threads.
@NathanWalker
NathanWalker merged commit 4b5ab0a into main Jun 16, 2026
8 checks passed
@NathanWalker
NathanWalker deleted the feat/worker-inspector branch June 16, 2026 17:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants