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..234c7668 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); @@ -284,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); @@ -295,6 +396,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 +471,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 +542,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 +631,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 +655,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 +694,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 +709,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 +963,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 +1005,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 +1103,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 */,