From b0f3439144031ea2d0b72da126c40d83c0f3094c Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 11 Jun 2026 10:14:30 -0300 Subject: [PATCH 1/2] feat(inspector): attach Chrome DevTools to Web Worker isolates 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 a dedicated CFRunLoopSource on the worker's runloop; while paused at a breakpoint a nested loop on the worker thread pumps the same queue without re-entering the runloop, so postMessage deliveries stay queued during a pause (matching Chrome's semantics). - Turn JsV8InspectorClient into the root session + router: handle Target.setAutoAttach natively (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. This also fixes the main-session fast path, which used to interrupt the main isolate for a pause aimed at any session. - Serve Network.loadNetworkResource and IO.read/IO.close on the socket thread for any session (sessionId echoed), so worker source maps load too. - 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 (or before Runtime.enable reaches a session) 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: inspector is created on the worker thread before RunModule (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. waitForDebuggerOnStart (pause new workers before their first line) is left as future work; workers currently announce waitingForDebugger: false. --- NativeScript/inspector/JsV8InspectorClient.h | 58 ++- NativeScript/inspector/JsV8InspectorClient.mm | 387 ++++++++++++++---- .../inspector/WorkerInspectorClient.h | 113 +++++ .../inspector/WorkerInspectorClient.mm | 297 ++++++++++++++ NativeScript/runtime/DataWrapper.h | 15 + NativeScript/runtime/Worker.mm | 5 + NativeScript/runtime/WorkerWrapper.mm | 63 +++ v8ios.xcodeproj/project.pbxproj | 8 + 8 files changed, 866 insertions(+), 80 deletions(-) create mode 100644 NativeScript/inspector/WorkerInspectorClient.h create mode 100644 NativeScript/inspector/WorkerInspectorClient.mm diff --git a/NativeScript/inspector/JsV8InspectorClient.h b/NativeScript/inspector/JsV8InspectorClient.h index 74420eeb..280fa2ce 100644 --- a/NativeScript/inspector/JsV8InspectorClient.h +++ b/NativeScript/inspector/JsV8InspectorClient.h @@ -5,6 +5,7 @@ #include #include +#include #include #include @@ -15,6 +16,8 @@ namespace v8_inspector { +class WorkerInspectorClient; + class JsV8InspectorClient : V8InspectorClient, V8Inspector::Channel { public: JsV8InspectorClient(tns::Runtime* runtime); @@ -23,6 +26,20 @@ class JsV8InspectorClient : V8InspectorClient, V8Inspector::Channel { void disconnect(); void dispatchMessage(const std::string& message); + // The single instance debugging the main isolate (created when IsDebug); + // also acts as the router for worker sessions. Null in release builds. + static JsV8InspectorClient* GetInstance(); + + // Thread-safe write to the connected frontend (no-op when disconnected). + void SendToFrontend(const std::string& message); + + // Worker targets (Chrome DevTools Target domain, flat-session mode). + // Register/Unregister are called on the worker's own thread. + void RegisterWorkerTarget(int workerId, WorkerInspectorClient* client); + void UnregisterWorkerTarget(int workerId); + // Called on a worker thread by the Debugger.pause interrupt. + void SchedulePauseInWorker(int workerId); + // Overrides of V8Inspector::Channel void sendResponse(int callId, std::unique_ptr message) override; void sendNotification(std::unique_ptr message) override; @@ -63,13 +80,39 @@ class JsV8InspectorClient : V8InspectorClient, V8Inspector::Channel { // Streams backing Network.loadNetworkResource responses, read by the // frontend through IO.read/IO.close (how Chrome DevTools fetches source - // maps from the target). Only touched from dispatchMessage (main thread). + // maps from the target). Served on the socket thread for any session; + // guarded by resourceStreamsMutex_. struct ResourceStream { std::string data; size_t offset = 0; }; std::map resourceStreams_; int lastStreamId_ = 0; + std::mutex resourceStreamsMutex_; + + // Worker targets announced to the frontend via Target.attachedToTarget, + // keyed by their flat-protocol sessionId. Guarded by workerTargetsMutex_; + // a registered client pointer stays valid until UnregisterWorkerTarget + // (which runs on the worker's own thread, before the client is deleted). + struct WorkerTarget { + int workerId; + WorkerInspectorClient* client; + bool announced = false; + }; + std::map workerTargets_; + std::mutex workerTargetsMutex_; + bool autoAttach_ = false; // guarded by workerTargetsMutex_ + + static JsV8InspectorClient* instance_; + + std::mutex senderMutex_; + + // Routes a frontend message carrying a sessionId to its worker session + // (socket thread). msgId is -1 when the message has no id. + void RouteToWorker(const std::string& sessionId, const std::string& method, + long long msgId, const std::string& message); + // Announces all not-yet-announced workers (after Target.setAutoAttach). + void AnnounceWorkerTargets(); // Override of V8InspectorClient v8::Local ensureDefaultContextInGroup( @@ -90,10 +133,15 @@ class JsV8InspectorClient : V8InspectorClient, V8Inspector::Channel { const v8::FunctionCallbackInfo& args); // Source map delivery to Chrome DevTools (Network.loadNetworkResource + IO - // domain). V8's inspector doesn't implement these embedder domains. - void HandleLoadNetworkResource(int msgId, const std::string& url); - void HandleIORead(int msgId, const std::string& handle, int size); - void HandleIOClose(int msgId, const std::string& handle); + // domain). V8's inspector doesn't implement these embedder domains. The + // handlers are filesystem-only and thread-safe; they serve any session + // (sessionId is echoed in the reply when non-empty). + void HandleLoadNetworkResource(int msgId, const std::string& url, + const std::string& sessionId); + void HandleIORead(int msgId, const std::string& handle, int size, + const std::string& sessionId); + void HandleIOClose(int msgId, const std::string& handle, + const std::string& sessionId); // {N} specific helpers bool CallDomainHandlerFunction(v8::Local context, diff --git a/NativeScript/inspector/JsV8InspectorClient.mm b/NativeScript/inspector/JsV8InspectorClient.mm index 84dd9e29..6f537e98 100644 --- a/NativeScript/inspector/JsV8InspectorClient.mm +++ b/NativeScript/inspector/JsV8InspectorClient.mm @@ -14,6 +14,7 @@ #include "InspectorServer.h" #include "JsV8InspectorClient.h" #include "RuntimeConfig.h" +#include "WorkerInspectorClient.h" #include "include/libplatform/libplatform.h" #include "third_party/json.hpp" #include "utils.h" @@ -133,8 +134,11 @@ bool ShouldRewriteSourceMapURLs() { this->messageLoopQueue_ = dispatch_queue_create("NativeScript.v8.inspector.message_loop_queue", DISPATCH_QUEUE_SERIAL); this->messageArrived_ = dispatch_semaphore_create(0); + instance_ = this; } +JsV8InspectorClient* JsV8InspectorClient::GetInstance() { return instance_; } + void JsV8InspectorClient::enableInspector(int argc, char** argv) { int waitForDebuggerSubscription; notify_register_dispatch( @@ -210,7 +214,10 @@ bool ShouldRewriteSourceMapURLs() { CFRunLoopWakeUp(runloop); } - this->sender_ = sender; + { + std::lock_guard lock(this->senderMutex_); + this->sender_ = sender; + } // this triggers a reconnection from the devtools so Debugger.scriptParsed etc. are all fired // again @@ -219,6 +226,64 @@ bool ShouldRewriteSourceMapURLs() { } void JsV8InspectorClient::onFrontendMessageReceived(const std::string& message) { + // Single parse, on the socket thread, used for routing and fast paths. + auto parsed = json::parse(message, nullptr, false); + std::string sessionId; + std::string method; + long long msgId = -1; + if (!parsed.is_discarded() && parsed.is_object()) { + if (parsed.contains("sessionId") && parsed["sessionId"].is_string()) { + sessionId = parsed["sessionId"].get(); + } + if (parsed.contains("method") && parsed["method"].is_string()) { + method = parsed["method"].get(); + } + if (parsed.contains("id") && parsed["id"].is_number()) { + msgId = parsed["id"].get(); + } + } + + // Network.loadNetworkResource and IO.read/IO.close are filesystem-only and + // session-agnostic (DevTools sends them on whichever session owns the + // script whose source map it wants). Serve them right here so they work + // for any session — even while the main isolate is paused. + if (method == "Network.loadNetworkResource") { + std::string url; + if (parsed.contains("params") && parsed["params"].contains("url")) { + url = parsed["params"]["url"].get(); + } + this->HandleLoadNetworkResource(static_cast(msgId), url, sessionId); + return; + } + + if (method == "IO.read" || method == "IO.close") { + std::string handle; + int size = 0; + if (parsed.contains("params")) { + const auto& params = parsed["params"]; + if (params.contains("handle")) { + handle = params["handle"].get(); + } + if (params.contains("size")) { + size = params["size"].get(); + } + } + + if (method == "IO.read") { + this->HandleIORead(static_cast(msgId), handle, size, sessionId); + } else { + this->HandleIOClose(static_cast(msgId), handle, sessionId); + } + return; + } + + // Messages carrying a sessionId belong to a worker target (flat-session + // protocol); route them to the worker's own thread. + if (!sessionId.empty()) { + this->RouteToWorker(sessionId, method, msgId, message); + return; + } + dispatch_sync(this->messagesQueue_, ^{ this->messages_.push(message); dispatch_semaphore_signal(messageArrived_); @@ -226,8 +291,7 @@ bool ShouldRewriteSourceMapURLs() { // Debugger.pause needs to interrupt V8 even if the main thread is busy // executing JS. RequestInterrupt fires at the next safe bytecode boundary. - auto parsed = json::parse(message, nullptr, false); - if (!parsed.is_discarded() && parsed.contains("method") && parsed["method"] == "Debugger.pause") { + if (method == "Debugger.pause") { isolate_->RequestInterrupt( [](Isolate* isolate, void* data) { auto client = static_cast(data); @@ -253,6 +317,33 @@ bool ShouldRewriteSourceMapURLs() { }); } +void JsV8InspectorClient::RouteToWorker(const std::string& sessionId, const std::string& method, + long long msgId, const std::string& message) { + std::lock_guard lock(this->workerTargetsMutex_); + auto it = this->workerTargets_.find(sessionId); + if (it == this->workerTargets_.end()) { + // The worker died (Target.detachedFromTarget was sent) or never existed. + if (msgId >= 0) { + json error = {{"id", msgId}, + {"sessionId", sessionId}, + {"error", {{"code", -32001}, {"message", "Session not found"}}}}; + this->SendToFrontend(error.dump()); + } + return; + } + + WorkerInspectorClient* client = it->second.client; + + // Same fast path as the main session: pause a worker that is busy + // executing JS. The worker isolate is guaranteed alive while we hold the + // registry lock (teardown unregisters before disposing it). + if (method == "Debugger.pause") { + client->RequestPauseInterrupt(); + } + + client->PushMessage(message); +} + void JsV8InspectorClient::init() { if (inspector_ != nullptr) { return; @@ -264,8 +355,11 @@ bool ShouldRewriteSourceMapURLs() { inspector_ = V8Inspector::create(isolate, this); - inspector_->contextCreated( - v8_inspector::V8ContextInfo(context, JsV8InspectorClient::contextGroupId, {})); + // Named so the DevTools console context selector has a label for the main + // isolate alongside the worker contexts (which are named by script url). + static const std::string mainContextName = "main"; + inspector_->contextCreated(v8_inspector::V8ContextInfo( + context, JsV8InspectorClient::contextGroupId, Make8BitStringView(mainContextName))); context_.Reset(isolate, context); @@ -295,6 +389,18 @@ bool ShouldRewriteSourceMapURLs() { this->isConnected_ = false; this->createInspectorSession(); + + // Reset worker sessions too: resume any paused worker and recreate its + // session so the (re)connecting frontend gets a clean slate, then forget + // the auto-attach state until it sends Target.setAutoAttach again. + { + std::lock_guard lock(this->workerTargetsMutex_); + this->autoAttach_ = false; + for (auto& entry : this->workerTargets_) { + entry.second.announced = false; + entry.second.client->PushMessage(WorkerInspectorClient::kResetSessionMessage); + } + } } void JsV8InspectorClient::runMessageLoopOnPause(int contextGroupId) { @@ -358,9 +464,16 @@ bool ShouldRewriteSourceMapURLs() { notify(ToStdString(stringView)); } -void JsV8InspectorClient::notify(const std::string& message) { - if (this->sender_) { - this->sender_(MaybeRewriteSourceMapURL(message)); +void JsV8InspectorClient::notify(const std::string& message) { this->SendToFrontend(message); } + +void JsV8InspectorClient::SendToFrontend(const std::string& message) { + std::function sender; + { + std::lock_guard lock(this->senderMutex_); + sender = this->sender_; + } + if (sender) { + sender(MaybeRewriteSourceMapURL(message)); } } @@ -422,40 +535,41 @@ bool ShouldRewriteSourceMapURLs() { return; } - // Chrome DevTools fetches source maps through the target: it sends - // Network.loadNetworkResource for the resolved sourceMappingURL and reads - // the returned stream with IO.read/IO.close. Neither domain is implemented - // by V8's inspector, so handle them here. - if (method == "Network.loadNetworkResource") { - std::string url; - if (json_message.contains("params") && json_message["params"].contains("url")) { - url = json_message["params"]["url"].get(); + // Note: Network.loadNetworkResource and IO.read/IO.close are handled + // earlier, in onFrontendMessageReceived, so they also work for worker + // sessions and while this (main) isolate is paused. + + // Chrome DevTools discovers worker targets through the Target domain: its + // ChildTargetManager sends Target.setAutoAttach {flatten: true} right + // after connecting and expects Target.attachedToTarget events for every + // worker. V8's inspector doesn't implement this embedder domain. + if (method == "Target.setAutoAttach") { + bool autoAttach = json_message.contains("params") && + json_message["params"].contains("autoAttach") && + json_message["params"]["autoAttach"].get(); + { + std::lock_guard lock(this->workerTargetsMutex_); + this->autoAttach_ = autoAttach; } - this->HandleLoadNetworkResource(json_message["id"].get(), url); - return; - } - if (method == "IO.read" || method == "IO.close") { - std::string handle; - int size = 0; - if (json_message.contains("params")) { - const auto& params = json_message["params"]; - if (params.contains("handle")) { - handle = params["handle"].get(); - } - if (params.contains("size")) { - size = params["size"].get(); - } - } + json response = {{"id", json_message["id"]}, {"result", json::object()}}; + this->notify(response.dump()); - if (method == "IO.read") { - this->HandleIORead(json_message["id"].get(), handle, size); - } else { - this->HandleIOClose(json_message["id"].get(), handle); + if (autoAttach) { + this->AnnounceWorkerTargets(); } return; } + // Ack the rest of the Target methods DevTools may send so they don't + // produce method-not-found errors from the V8 session. + if (method == "Target.setDiscoverTargets" || method == "Target.setRemoteLocations" || + method == "Target.detachFromTarget") { + json response = {{"id", json_message["id"]}, {"result", json::object()}}; + this->notify(response.dump()); + return; + } + // parse incoming message as JSON Local arg; success = v8::JSON::Parse(context, tns::ToV8String(isolate, message)).ToLocal(&arg); @@ -510,7 +624,19 @@ bool ShouldRewriteSourceMapURLs() { isolate->PerformMicrotaskCheckpoint(); } -void JsV8InspectorClient::HandleLoadNetworkResource(int msgId, const std::string& url) { +namespace { +// Echo the flat-protocol sessionId so the frontend routes the reply to the +// right (worker) session; root-session messages carry none. +json WithSessionId(json message, const std::string& sessionId) { + if (!sessionId.empty()) { + message["sessionId"] = sessionId; + } + return message; +} +} // namespace + +void JsV8InspectorClient::HandleLoadNetworkResource(int msgId, const std::string& url, + const std::string& sessionId) { std::string path; if (url.rfind(kSourceMapScheme, 0) == 0) { path = url.substr(strlen(kSourceMapScheme)); @@ -522,7 +648,7 @@ bool ShouldRewriteSourceMapURLs() { // pre-existing behavior for http(s) urls. json error = {{"id", msgId}, {"error", {{"code", -32000}, {"message", "Unsupported URL scheme"}}}}; - this->notify(error.dump()); + this->SendToFrontend(WithSessionId(error, sessionId).dump()); return; } @@ -561,8 +687,12 @@ bool ShouldRewriteSourceMapURLs() { json resource; if (loaded) { - std::string handle = "ns-network-resource-" + std::to_string(++lastStreamId_); - resourceStreams_[handle] = {std::move(content), 0}; + std::string handle; + { + std::lock_guard lock(this->resourceStreamsMutex_); + handle = "ns-network-resource-" + std::to_string(++lastStreamId_); + resourceStreams_[handle] = {std::move(content), 0}; + } resource = {{"success", true}, {"httpStatusCode", 200}, {"stream", handle}}; } else { resource = {{"success", false}, @@ -572,45 +702,130 @@ bool ShouldRewriteSourceMapURLs() { } json response = {{"id", msgId}, {"result", {{"resource", resource}}}}; - this->notify(response.dump()); + this->SendToFrontend(WithSessionId(response, sessionId).dump()); } -void JsV8InspectorClient::HandleIORead(int msgId, const std::string& handle, int size) { - auto it = resourceStreams_.find(handle); - if (it == resourceStreams_.end()) { - json error = {{"id", msgId}, - {"error", {{"code", -32602}, {"message", "Invalid stream handle"}}}}; - this->notify(error.dump()); - return; - } +void JsV8InspectorClient::HandleIORead(int msgId, const std::string& handle, int size, + const std::string& sessionId) { + json result; + { + std::lock_guard lock(this->resourceStreamsMutex_); + auto it = resourceStreams_.find(handle); + if (it == resourceStreams_.end()) { + json error = {{"id", msgId}, + {"error", {{"code", -32602}, {"message", "Invalid stream handle"}}}}; + this->SendToFrontend(WithSessionId(error, sessionId).dump()); + return; + } - ResourceStream& stream = it->second; - constexpr size_t kDefaultChunkSize = 1024 * 1024; - size_t chunkSize = size > 0 ? static_cast(size) : kDefaultChunkSize; - size_t remaining = stream.data.size() - stream.offset; - chunkSize = std::min(chunkSize, remaining); + ResourceStream& stream = it->second; + constexpr size_t kDefaultChunkSize = 1024 * 1024; + size_t chunkSize = size > 0 ? static_cast(size) : kDefaultChunkSize; + size_t remaining = stream.data.size() - stream.offset; + chunkSize = std::min(chunkSize, remaining); - json result; - if (chunkSize == 0) { - // DevTools ignores any data sent alongside eof, so only signal it once - // the whole stream has been delivered. - result = {{"data", ""}, {"eof", true}, {"base64Encoded", false}}; - } else { - // Base64 keeps arbitrary file bytes intact through the JSON transport. - NSData* chunk = [NSData dataWithBytes:stream.data.data() + stream.offset length:chunkSize]; - NSString* encoded = [chunk base64EncodedStringWithOptions:0]; - stream.offset += chunkSize; - result = {{"data", [encoded UTF8String]}, {"eof", false}, {"base64Encoded", true}}; + if (chunkSize == 0) { + // DevTools ignores any data sent alongside eof, so only signal it once + // the whole stream has been delivered. + result = {{"data", ""}, {"eof", true}, {"base64Encoded", false}}; + } else { + // Base64 keeps arbitrary file bytes intact through the JSON transport. + NSData* chunk = [NSData dataWithBytes:stream.data.data() + stream.offset length:chunkSize]; + NSString* encoded = [chunk base64EncodedStringWithOptions:0]; + stream.offset += chunkSize; + result = {{"data", [encoded UTF8String]}, {"eof", false}, {"base64Encoded", true}}; + } } json response = {{"id", msgId}, {"result", result}}; - this->notify(response.dump()); + this->SendToFrontend(WithSessionId(response, sessionId).dump()); } -void JsV8InspectorClient::HandleIOClose(int msgId, const std::string& handle) { - resourceStreams_.erase(handle); +void JsV8InspectorClient::HandleIOClose(int msgId, const std::string& handle, + const std::string& sessionId) { + { + std::lock_guard lock(this->resourceStreamsMutex_); + resourceStreams_.erase(handle); + } json response = {{"id", msgId}, {"result", json::object()}}; - this->notify(response.dump()); + this->SendToFrontend(WithSessionId(response, sessionId).dump()); +} + +void JsV8InspectorClient::RegisterWorkerTarget(int workerId, WorkerInspectorClient* client) { + std::lock_guard lock(this->workerTargetsMutex_); + WorkerTarget target{workerId, client, false}; + + if (this->isConnected_ && this->autoAttach_) { + target.announced = true; + json attached = {{"method", "Target.attachedToTarget"}, + {"params", + {{"sessionId", client->SessionId()}, + {"targetInfo", + {{"targetId", client->TargetId()}, + {"type", "worker"}, + {"title", client->Url()}, + {"url", client->Url()}, + {"attached", true}, + {"canAccessOpener", false}}}, + {"waitingForDebugger", false}}}}; + this->SendToFrontend(attached.dump()); + } + + this->workerTargets_.emplace(client->SessionId(), target); +} + +void JsV8InspectorClient::UnregisterWorkerTarget(int workerId) { + std::lock_guard lock(this->workerTargetsMutex_); + for (auto it = this->workerTargets_.begin(); it != this->workerTargets_.end(); ++it) { + if (it->second.workerId != workerId) { + continue; + } + + if (it->second.announced && this->isConnected_) { + json detached = {{"method", "Target.detachedFromTarget"}, + {"params", + {{"sessionId", it->second.client->SessionId()}, + {"targetId", it->second.client->TargetId()}}}}; + this->SendToFrontend(detached.dump()); + } + + this->workerTargets_.erase(it); + return; + } +} + +void JsV8InspectorClient::AnnounceWorkerTargets() { + std::lock_guard lock(this->workerTargetsMutex_); + for (auto& entry : this->workerTargets_) { + WorkerTarget& target = entry.second; + if (target.announced) { + continue; + } + target.announced = true; + + json attached = {{"method", "Target.attachedToTarget"}, + {"params", + {{"sessionId", target.client->SessionId()}, + {"targetInfo", + {{"targetId", target.client->TargetId()}, + {"type", "worker"}, + {"title", target.client->Url()}, + {"url", target.client->Url()}, + {"attached", true}, + {"canAccessOpener", false}}}, + {"waitingForDebugger", false}}}}; + this->SendToFrontend(attached.dump()); + } +} + +void JsV8InspectorClient::SchedulePauseInWorker(int workerId) { + std::lock_guard lock(this->workerTargetsMutex_); + for (auto& entry : this->workerTargets_) { + if (entry.second.workerId == workerId) { + entry.second.client->SchedulePauseFromInterrupt(); + return; + } + } } Local JsV8InspectorClient::ensureDefaultContextInGroup(int contextGroupId) { @@ -741,16 +956,33 @@ bool ShouldRewriteSourceMapURLs() { void JsV8InspectorClient::consoleLog(v8::Isolate* isolate, ConsoleAPIType method, const std::vector>& args) { - if (!isConnected_) { - return; - } - // Note, here we access private API auto* impl = reinterpret_cast(inspector_.get()); - auto* session = reinterpret_cast(session_.get()); if (impl->isolate() != isolate) { - // we don't currently support logging from a worker thread/isolate + // Logging from a worker isolate: forward to that worker's own inspector. + // We're on the worker's thread here (console.* runs where it's called), + // which is also the only thread that deletes the client — so the pointer + // obtained under the registry lock stays valid for the call. + tns::Runtime* runtime = tns::Runtime::GetRuntime(isolate); + if (runtime == nullptr) { + return; + } + + WorkerInspectorClient* client = nullptr; + { + std::lock_guard lock(this->workerTargetsMutex_); + for (auto& entry : this->workerTargets_) { + if (entry.second.workerId == runtime->WorkerId()) { + client = entry.second.client; + break; + } + } + } + + if (client != nullptr) { + client->consoleLog(method, args); + } return; } @@ -766,7 +998,10 @@ bool ShouldRewriteSourceMapURLs() { currentTimeMS(), method, args, String16{}, std::move(stackImpl)); - session->runtimeAgent()->messageAdded(msg.get()); + // Going through the message storage both reports to enabled sessions and + // keeps the message for replay on Runtime.enable, so anything logged + // before the frontend attaches shows up as console history. + impl->ensureConsoleMessageStorage(contextGroupId)->addMessage(std::move(msg)); } bool JsV8InspectorClient::CallDomainHandlerFunction(Local context, @@ -861,4 +1096,6 @@ bool ShouldRewriteSourceMapURLs() { std::map*> JsV8InspectorClient::Domains; +JsV8InspectorClient* JsV8InspectorClient::instance_ = nullptr; + } // namespace v8_inspector diff --git a/NativeScript/inspector/WorkerInspectorClient.h b/NativeScript/inspector/WorkerInspectorClient.h new file mode 100644 index 00000000..210e8d5c --- /dev/null +++ b/NativeScript/inspector/WorkerInspectorClient.h @@ -0,0 +1,113 @@ +#ifndef WorkerInspectorClient_h +#define WorkerInspectorClient_h + +#include +#include + +#include +#include +#include +#include +#include + +#include "include/v8-inspector.h" +#include "src/inspector/v8-console-message.h" + +namespace v8_inspector { + +// V8 inspector for a single worker isolate, exposed to Chrome DevTools as a +// child target ("Target.attachedToTarget") and addressed with flat-session +// CDP messages (a top-level "sessionId" field). One instance per worker. +// +// Threading: constructed, dispatched into, and destroyed on the worker's own +// thread (V8's inspector is not thread-safe). Other threads interact only +// through PushMessage/NotifyTerminating/RequestPauseInterrupt. Incoming +// messages are queued and drained via a CFRunLoopSource on the worker's +// runloop; while paused, a nested loop on the worker thread pumps the same +// queue (the runloop is NOT re-entered, so postMessage deliveries stay queued +// during a pause, matching Chrome's semantics). +class WorkerInspectorClient final : public V8InspectorClient, + public V8Inspector::Channel { + public: + // Worker thread, with the worker isolate locked and its context created. + WorkerInspectorClient(int workerId, v8::Isolate* isolate, + CFRunLoopRef workerLoop, const std::string& url); + ~WorkerInspectorClient() override; + + int WorkerId() const { return workerId_; } + const std::string& SessionId() const { return sessionId_; } + const std::string& TargetId() const { return targetId_; } + const std::string& Url() const { return url_; } + + // Any thread. Queues a CDP message (already stripped of routing concerns) + // and wakes the worker runloop / a nested pause loop. + void PushMessage(const std::string& message); + + // Any thread. Unblocks a paused worker and makes it drop all inspector + // work; used by WorkerWrapper::Terminate together with TerminateExecution. + void NotifyTerminating(); + + // Any thread (with the worker isolate guaranteed alive). Schedules a pause + // at the next statement even if the worker is busy executing JS. + void RequestPauseInterrupt(); + + // Worker thread (from the interrupt requested above). + void SchedulePauseFromInterrupt(); + + // Worker thread. Mirrors JsV8InspectorClient::consoleLog for this isolate. + void consoleLog(ConsoleAPIType method, + const std::vector>& args); + + // Internal control message pushed by the root client on frontend + // reconnect; resumes the worker if paused and recreates its session. + static constexpr const char* kResetSessionMessage = + "{\"__nsInternal\":\"resetSession\"}"; + + // Overrides of V8Inspector::Channel + void sendResponse(int callId, std::unique_ptr message) override; + void sendNotification(std::unique_ptr message) override; + void flushProtocolNotifications() override; + + // Overrides of V8InspectorClient + void runMessageLoopOnPause(int contextGroupId) override; + void quitMessageLoopOnPause() override; + + private: + static constexpr int contextGroupId = 1; + + void DrainIncoming(); + std::string PopMessage(); + void DispatchOne(const std::string& message); + void HandleResetRequest(); + void DoResetSession(); + void MaybeResetSession(); + void SendWrapped(const std::string& message); + + v8::Local ensureDefaultContextInGroup( + int contextGroupId) override; + + int workerId_; + std::string sessionId_; + std::string targetId_; + std::string url_; + v8::Isolate* isolate_; + CFRunLoopRef workerLoop_; + CFRunLoopSourceRef source_ = nullptr; + + std::unique_ptr inspector_; + std::unique_ptr session_; + v8::Persistent context_; + + std::queue incoming_; + std::mutex incomingMutex_; + dispatch_semaphore_t messageArrived_; + + std::atomic dying_{false}; + std::atomic pauseTerminated_{false}; + bool runningPauseLoop_ = false; // worker thread only + bool pendingReset_ = false; // worker thread only +}; + +} // namespace v8_inspector + +#endif /* WorkerInspectorClient_h */ diff --git a/NativeScript/inspector/WorkerInspectorClient.mm b/NativeScript/inspector/WorkerInspectorClient.mm new file mode 100644 index 00000000..63f0eb4a --- /dev/null +++ b/NativeScript/inspector/WorkerInspectorClient.mm @@ -0,0 +1,297 @@ +#include "WorkerInspectorClient.h" + +#include "src/inspector/v8-inspector-impl.h" +#include "src/inspector/v8-inspector-session-impl.h" +#include "src/inspector/v8-runtime-agent-impl.h" +#include "src/inspector/v8-stack-trace-impl.h" + +#include "Caches.h" +#include "Helpers.h" +#include "JsV8InspectorClient.h" +#include "include/libplatform/libplatform.h" +#include "utils.h" + +using namespace v8; + +namespace v8_inspector { + +namespace { +StringView Make8BitStringView(const std::string& value) { + return StringView(reinterpret_cast(value.data()), value.size()); +} +} // namespace + +WorkerInspectorClient::WorkerInspectorClient(int workerId, Isolate* isolate, + CFRunLoopRef workerLoop, const std::string& url) + : workerId_(workerId), + sessionId_("NS_WORKER_" + std::to_string(workerId)), + targetId_("ns-worker-" + std::to_string(workerId)), + url_(url), + isolate_(isolate), + workerLoop_(workerLoop) { + messageArrived_ = dispatch_semaphore_create(0); + + CFRunLoopSourceContext sourceContext = { + 0, this, + 0, 0, + 0, 0, + 0, 0, + 0, [](void* info) { + static_cast(info) + ->DrainIncoming(); }}; + source_ = CFRunLoopSourceCreate(kCFAllocatorDefault, 0, &sourceContext); + CFRunLoopAddSource(workerLoop_, source_, kCFRunLoopCommonModes); + + v8::Locker locker(isolate_); + Isolate::Scope isolate_scope(isolate_); + HandleScope handle_scope(isolate_); + Local context = tns::Caches::Get(isolate_)->GetContext(); + + inspector_ = V8Inspector::create(isolate_, this); + // Name the context after the worker script: the DevTools console context + // selector labels entries with the context's name (or its origin as a + // fallback) — with neither set the dropdown rows are blank and + // unselectable. + V8ContextInfo contextInfo(context, contextGroupId, Make8BitStringView(url_)); + contextInfo.origin = Make8BitStringView(url_); + inspector_->contextCreated(contextInfo); + context_.Reset(isolate_, context); + session_ = inspector_->connect(contextGroupId, this, {}); +} + +WorkerInspectorClient::~WorkerInspectorClient() { + dying_ = true; + + if (source_ != nullptr) { + CFRunLoopRemoveSource(workerLoop_, source_, kCFRunLoopCommonModes); + CFRunLoopSourceInvalidate(source_); + CFRelease(source_); + source_ = nullptr; + } + + v8::Locker locker(isolate_); + Isolate::Scope isolate_scope(isolate_); + HandleScope handle_scope(isolate_); + if (session_ != nullptr) { + session_->resume(); + session_.reset(); + } + inspector_.reset(); + context_.Reset(); +} + +void WorkerInspectorClient::PushMessage(const std::string& message) { + if (dying_) { + return; + } + + { + std::lock_guard lock(incomingMutex_); + incoming_.push(message); + } + + if (source_ != nullptr && CFRunLoopSourceIsValid(source_)) { + CFRunLoopSourceSignal(source_); + CFRunLoopWakeUp(workerLoop_); + } + dispatch_semaphore_signal(messageArrived_); +} + +std::string WorkerInspectorClient::PopMessage() { + std::lock_guard lock(incomingMutex_); + if (incoming_.empty()) { + return ""; + } + std::string message = incoming_.front(); + incoming_.pop(); + return message; +} + +void WorkerInspectorClient::DrainIncoming() { + std::string message; + while (!dying_ && !(message = this->PopMessage()).empty()) { + this->DispatchOne(message); + } + this->MaybeResetSession(); +} + +void WorkerInspectorClient::DispatchOne(const std::string& message) { + if (message == kResetSessionMessage) { + this->HandleResetRequest(); + return; + } + + if (session_ == nullptr) { + return; + } + + v8::Locker locker(isolate_); + Isolate::Scope isolate_scope(isolate_); + HandleScope handle_scope(isolate_); + session_->dispatchProtocolMessage(Make8BitStringView(message)); + isolate_->PerformMicrotaskCheckpoint(); +} + +void WorkerInspectorClient::HandleResetRequest() { + if (runningPauseLoop_) { + // We're inside session_->dispatchProtocolMessage somewhere up the stack — + // resume now (which exits the nested pause loop) and swap the session + // only once that stack has fully unwound, from DrainIncoming. + pendingReset_ = true; + { + v8::Locker locker(isolate_); + Isolate::Scope isolate_scope(isolate_); + HandleScope handle_scope(isolate_); + if (session_ != nullptr) { + session_->resume(); + } + } + if (source_ != nullptr && CFRunLoopSourceIsValid(source_)) { + CFRunLoopSourceSignal(source_); + CFRunLoopWakeUp(workerLoop_); + } + return; + } + + this->DoResetSession(); +} + +void WorkerInspectorClient::DoResetSession() { + v8::Locker locker(isolate_); + Isolate::Scope isolate_scope(isolate_); + HandleScope handle_scope(isolate_); + if (session_ != nullptr) { + session_->resume(); + session_.reset(); + } + session_ = inspector_->connect(contextGroupId, this, {}); +} + +void WorkerInspectorClient::MaybeResetSession() { + if (pendingReset_ && !runningPauseLoop_ && !dying_) { + pendingReset_ = false; + this->DoResetSession(); + } +} + +void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) { + if (runningPauseLoop_ || dying_) { + return; + } + runningPauseLoop_ = true; + pauseTerminated_ = false; + + while (!pauseTerminated_ && !dying_) { + std::string message = this->PopMessage(); + bool shouldWait = message.empty(); + if (!shouldWait) { + this->DispatchOne(message); + } + + std::shared_ptr platform = tns::Runtime::GetPlatform(); + platform::PumpMessageLoop(platform.get(), isolate_, platform::MessageLoopBehavior::kDoNotWait); + if (shouldWait && !pauseTerminated_ && !dying_) { + dispatch_semaphore_wait(messageArrived_, + dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_MSEC)); // 1ms + } + } + + runningPauseLoop_ = false; +} + +void WorkerInspectorClient::quitMessageLoopOnPause() { pauseTerminated_ = true; } + +void WorkerInspectorClient::NotifyTerminating() { + dying_ = true; + pauseTerminated_ = true; + dispatch_semaphore_signal(messageArrived_); +} + +void WorkerInspectorClient::RequestPauseInterrupt() { + isolate_->RequestInterrupt( + [](Isolate* isolate, void* data) { + // Runs on the worker thread mid-JS. Teardown also happens on the + // worker thread, so the client either still exists or this resolves + // to nothing — re-resolve through the registry instead of capturing + // the pointer. + int workerId = static_cast(reinterpret_cast(data)); + JsV8InspectorClient* root = JsV8InspectorClient::GetInstance(); + if (root != nullptr) { + root->SchedulePauseInWorker(workerId); + } + }, + reinterpret_cast(static_cast(workerId_))); +} + +void WorkerInspectorClient::SchedulePauseFromInterrupt() { + if (session_ != nullptr) { + session_->schedulePauseOnNextStatement({}, {}); + } +} + +void WorkerInspectorClient::sendResponse(int callId, std::unique_ptr message) { + this->SendWrapped(ToStdString(message->string())); +} + +void WorkerInspectorClient::sendNotification(std::unique_ptr message) { + this->SendWrapped(ToStdString(message->string())); +} + +void WorkerInspectorClient::flushProtocolNotifications() {} + +void WorkerInspectorClient::SendWrapped(const std::string& message) { + if (message.empty() || message[0] != '{') { + return; + } + + // Flat-session protocol: tag everything this session emits with its + // sessionId so the frontend routes it to the right child target. + std::string wrapped; + wrapped.reserve(message.size() + sessionId_.size() + 16); + wrapped += "{\"sessionId\":\""; + wrapped += sessionId_; + wrapped += "\""; + if (message.size() > 2) { + wrapped += ","; + } + wrapped.append(message, 1, std::string::npos); + + JsV8InspectorClient* root = JsV8InspectorClient::GetInstance(); + if (root != nullptr) { + root->SendToFrontend(wrapped); + } +} + +void WorkerInspectorClient::consoleLog(ConsoleAPIType method, + const std::vector>& args) { + if (inspector_ == nullptr) { + return; + } + + // Note, here we access private API (mirrors JsV8InspectorClient::consoleLog) + auto* impl = reinterpret_cast(inspector_.get()); + + Local stack = + StackTrace::CurrentStackTrace(isolate_, 1, StackTrace::StackTraceOptions::kDetailed); + std::unique_ptr stackImpl = impl->debugger()->createStackTrace(stack); + + Local context = context_.Get(isolate_); + const int contextId = V8ContextInfo::executionContextId(context); + + std::unique_ptr msg = V8ConsoleMessage::createForConsoleAPI( + context, contextId, contextGroupId, impl, currentTimeMS(), method, args, String16{}, + std::move(stackImpl)); + + // Going through the message storage both reports to the session when the + // frontend has enabled the Runtime agent AND keeps the message for replay + // on Runtime.enable. Workers log most of their output (module top-level, + // early onmessage work) before DevTools attaches and enables the session; + // delivering straight to the runtime agent would silently drop all of it. + impl->ensureConsoleMessageStorage(contextGroupId)->addMessage(std::move(msg)); +} + +Local WorkerInspectorClient::ensureDefaultContextInGroup(int contextGroupId) { + return context_.Get(isolate_); +} + +} // namespace v8_inspector diff --git a/NativeScript/runtime/DataWrapper.h b/NativeScript/runtime/DataWrapper.h index e06e868e..2625acd0 100644 --- a/NativeScript/runtime/DataWrapper.h +++ b/NativeScript/runtime/DataWrapper.h @@ -2,6 +2,7 @@ #define DataWrapper_h #include +#include #include #include "Common.h" @@ -9,6 +10,10 @@ #include "Metadata.h" #include "libffi.h" +namespace v8_inspector { +class WorkerInspectorClient; +} + namespace tns { class PrimitiveDataWrapper; @@ -500,6 +505,12 @@ class WorkerWrapper : public BaseDataWrapper { std::shared_ptr)> onMessage); + // Debugger support (no-ops in release builds). CreateInspector runs on the + // worker thread after the worker Runtime is initialized; DestroyInspector + // runs on the worker thread during teardown, before the Runtime is deleted. + void CreateInspector(v8::Isolate* isolate, const std::string& scriptPath); + void DestroyInspector(); + void Start(std::shared_ptr> poWorker, std::function func, int qualityOfService = -1); void CallOnErrorHandlers(v8::TryCatch& tc); @@ -543,6 +554,10 @@ class WorkerWrapper : public BaseDataWrapper { ConcurrentQueue queue_; static std::atomic nextId_; int workerId_; + // Owned by the worker thread; inspectorMutex_ makes Terminate() (main + // thread) and DestroyInspector() (worker thread) agree on liveness. + v8_inspector::WorkerInspectorClient* inspector_ = nullptr; + std::mutex inspectorMutex_; void BackgroundLooper(std::function func); void DrainPendingTasks(); diff --git a/NativeScript/runtime/Worker.mm b/NativeScript/runtime/Worker.mm index fb85540e..e31adca7 100644 --- a/NativeScript/runtime/Worker.mm +++ b/NativeScript/runtime/Worker.mm @@ -147,6 +147,11 @@ throw NativeScriptException( int workerId = worker->WorkerId(); Worker::SetWorkerId(isolate, workerId); + // Expose this worker to an attached Chrome DevTools frontend as a + // child target (no-op in release builds). Created before RunModule so + // the worker's scripts are visible to the debugger from the start. + worker->CreateInspector(isolate, resolvedPath); + TryCatch tc(isolate); // Debug: Log worker execution diff --git a/NativeScript/runtime/WorkerWrapper.mm b/NativeScript/runtime/WorkerWrapper.mm index a08820e9..65f3855c 100644 --- a/NativeScript/runtime/WorkerWrapper.mm +++ b/NativeScript/runtime/WorkerWrapper.mm @@ -4,6 +4,9 @@ #include "DataWrapper.h" #include "Helpers.h" #include "Runtime.h" +#include "RuntimeConfig.h" +#include "inspector/JsV8InspectorClient.h" +#include "inspector/WorkerInspectorClient.h" using namespace v8; @@ -113,6 +116,10 @@ } } + // The inspector must be gone before the Runtime (and with it the isolate) + // is deleted below. + this->DestroyInspector(); + this->isDisposed_ = true; Runtime* runtime = Runtime::GetCurrentRuntime(); if (runtime != nullptr) { @@ -138,11 +145,67 @@ if (this->workerIsolate_ != nullptr) { this->workerIsolate_->TerminateExecution(); } + { + // A worker paused at a breakpoint sits in the inspector's nested pause + // loop, not in the CFRunLoop — kick it loose so TerminateExecution and + // the runloop stop below can take effect. + std::lock_guard lock(this->inspectorMutex_); + if (this->inspector_ != nullptr) { + this->inspector_->NotifyTerminating(); + } + } this->queue_.Terminate(); this->isRunning_ = false; } } +void WorkerWrapper::CreateInspector(Isolate* isolate, const std::string& scriptPath) { + if (!RuntimeConfig.IsDebug) { + return; + } + + v8_inspector::JsV8InspectorClient* root = v8_inspector::JsV8InspectorClient::GetInstance(); + if (root == nullptr) { + return; + } + + // Same url scheme the module loader reports in Debugger.scriptParsed. + std::string url = "file://" + ReplaceAll(scriptPath, RuntimeConfig.BaseDir, ""); + + auto* client = + new v8_inspector::WorkerInspectorClient(this->workerId_, isolate, CFRunLoopGetCurrent(), url); + { + std::lock_guard lock(this->inspectorMutex_); + this->inspector_ = client; + } + + // Only register once the client is fully constructed: registration makes + // it reachable from the socket thread. + root->RegisterWorkerTarget(this->workerId_, client); +} + +void WorkerWrapper::DestroyInspector() { + v8_inspector::WorkerInspectorClient* client = nullptr; + { + std::lock_guard lock(this->inspectorMutex_); + client = this->inspector_; + this->inspector_ = nullptr; + } + + if (client == nullptr) { + return; + } + + // Unregister first: after this returns no other thread can reach the + // client (routing holds the registry lock while pushing messages). + v8_inspector::JsV8InspectorClient* root = v8_inspector::JsV8InspectorClient::GetInstance(); + if (root != nullptr) { + root->UnregisterWorkerTarget(this->workerId_); + } + + delete client; +} + void WorkerWrapper::CallOnErrorHandlers(TryCatch& tc) { if (this->isTerminating_) { return; diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 2d6a2d4d..19ca5ef8 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -338,6 +338,8 @@ F1F30E8C2B58FE28006A62C0 /* ada.cpp in Sources */ = {isa = PBXBuildFile; fileRef = F1F30E882B58FE28006A62C0 /* ada.cpp */; }; F6191AAE29C0FCE8003F588F /* JsV8InspectorClient.h in Headers */ = {isa = PBXBuildFile; fileRef = F6191AA629C0FCE7003F588F /* JsV8InspectorClient.h */; }; F6191AB029C0FCE8003F588F /* JsV8InspectorClient.mm in Sources */ = {isa = PBXBuildFile; fileRef = F6191AA829C0FCE7003F588F /* JsV8InspectorClient.mm */; }; + A1B2C3D40000000000000003 /* WorkerInspectorClient.h in Headers */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000001 /* WorkerInspectorClient.h */; }; + A1B2C3D40000000000000004 /* WorkerInspectorClient.mm in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40000000000000002 /* WorkerInspectorClient.mm */; }; F6191AB129C0FCE8003F588F /* InspectorServer.h in Headers */ = {isa = PBXBuildFile; fileRef = F6191AA929C0FCE7003F588F /* InspectorServer.h */; }; F6191AB229C0FCE8003F588F /* InspectorServer.mm in Sources */ = {isa = PBXBuildFile; fileRef = F6191AAA29C0FCE7003F588F /* InspectorServer.mm */; settings = {COMPILER_FLAGS = "-fobjc-arc"; }; }; F6191AB629C0FF87003F588F /* utils.h in Headers */ = {isa = PBXBuildFile; fileRef = F6191AB429C0FF86003F588F /* utils.h */; }; @@ -848,6 +850,8 @@ F1F30E882B58FE28006A62C0 /* ada.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = ada.cpp; sourceTree = ""; }; F6191AA629C0FCE7003F588F /* JsV8InspectorClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = JsV8InspectorClient.h; sourceTree = ""; }; F6191AA829C0FCE7003F588F /* JsV8InspectorClient.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = JsV8InspectorClient.mm; sourceTree = ""; }; + A1B2C3D40000000000000001 /* WorkerInspectorClient.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = WorkerInspectorClient.h; sourceTree = ""; }; + A1B2C3D40000000000000002 /* WorkerInspectorClient.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = WorkerInspectorClient.mm; sourceTree = ""; }; F6191AA929C0FCE7003F588F /* InspectorServer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = InspectorServer.h; sourceTree = ""; }; F6191AAA29C0FCE7003F588F /* InspectorServer.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = InspectorServer.mm; sourceTree = ""; }; F6191AB429C0FF86003F588F /* utils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = utils.h; sourceTree = ""; }; @@ -1552,6 +1556,8 @@ F6191AB529C0FF86003F588F /* utils.mm */, F6191AA629C0FCE7003F588F /* JsV8InspectorClient.h */, F6191AA829C0FCE7003F588F /* JsV8InspectorClient.mm */, + A1B2C3D40000000000000001 /* WorkerInspectorClient.h */, + A1B2C3D40000000000000002 /* WorkerInspectorClient.mm */, F6191AA929C0FCE7003F588F /* InspectorServer.h */, F6191AAA29C0FCE7003F588F /* InspectorServer.mm */, 91B25A0829DAC83D00E3CE04 /* ns-v8-tracing-agent-impl.mm */, @@ -1592,6 +1598,7 @@ C22536B5241A318900192740 /* ffitarget.h in Headers */, F1F30E772B58FC74006A62C0 /* URLImpl.h in Headers */, F6191AAE29C0FCE8003F588F /* JsV8InspectorClient.h in Headers */, + A1B2C3D40000000000000003 /* WorkerInspectorClient.h in Headers */, 6573B9D0291FE29F00B0ED7C /* JSIV8ValueConverter.h in Headers */, C20AB5E726E1015300E2B41D /* OneByteStringResource.h in Headers */, C2DDEBAC229EAC8300345BFE /* ObjectManager.h in Headers */, @@ -2225,6 +2232,7 @@ C2DDEBAF229EAC8300345BFE /* ObjectManager.mm in Sources */, AA5DBFD82EC19216008D12F9 /* DevFlags.mm in Sources */, F6191AB029C0FCE8003F588F /* JsV8InspectorClient.mm in Sources */, + A1B2C3D40000000000000004 /* WorkerInspectorClient.mm in Sources */, C2DDEB9C229EAC8300345BFE /* ClassBuilder.cpp in Sources */, C266569322AFFF7E00EE15CC /* Pointer.cpp in Sources */, F1CB51832D5C37100042555E /* URLPatternImpl.cpp in Sources */, From 026e407b500156a1581e3b136cb8467ea652dd2d Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Thu, 11 Jun 2026 17:45:32 -0300 Subject: [PATCH 2/2] feat(inspector): clear resource streams on disconnect --- NativeScript/inspector/JsV8InspectorClient.mm | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/NativeScript/inspector/JsV8InspectorClient.mm b/NativeScript/inspector/JsV8InspectorClient.mm index 6f537e98..234c7668 100644 --- a/NativeScript/inspector/JsV8InspectorClient.mm +++ b/NativeScript/inspector/JsV8InspectorClient.mm @@ -378,6 +378,13 @@ bool ShouldRewriteSourceMapURLs() { } void JsV8InspectorClient::disconnect() { + // Resource stream handles only have meaning to the frontend that opened + // them, so drop any streams it never closed via IO.close. + { + std::lock_guard lock(this->resourceStreamsMutex_); + this->resourceStreams_.clear(); + } + Isolate* isolate = isolate_; v8::Locker locker(isolate); Isolate::Scope isolate_scope(isolate);