diff --git a/test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java b/test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java index 9aae3f1db..fd1ef7329 100644 --- a/test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java +++ b/test-app/app/src/main/java/com/tns/AndroidJsV8Inspector.java @@ -298,15 +298,20 @@ protected void onMessage(final NanoWSD.WebSocketFrame message) { // Network.loadNetworkResource / IO.read / IO.close are served from // disk on this thread so source maps load even while the isolate is - // paused at a breakpoint or busy running JS; Debugger.pause schedules - // a V8 interrupt and still flows through the queue. + // paused at a breakpoint or busy running JS; Target domain commands + // and worker-session messages (top-level sessionId) are routed here + // too. Debugger.pause schedules a V8 interrupt and still flows + // through the queue. A null return means "not handled" (queue it); + // an empty string means handled with nothing left to send. String fastPathResponse = handleMessageOnSocketThread(message.getTextPayload()); if (fastPathResponse != null) { - try { - send(fastPathResponse); - } catch (IOException e) { - if (com.tns.Runtime.isDebuggable()) { - e.printStackTrace(); + if (!fastPathResponse.isEmpty()) { + try { + send(fastPathResponse); + } catch (IOException e) { + if (com.tns.Runtime.isDebuggable()) { + e.printStackTrace(); + } } } return; diff --git a/test-app/runtime/CMakeLists.txt b/test-app/runtime/CMakeLists.txt index 2f90b7eb4..a8e428801 100644 --- a/test-app/runtime/CMakeLists.txt +++ b/test-app/runtime/CMakeLists.txt @@ -74,6 +74,7 @@ if (NOT OPTIMIZED_BUILD OR OPTIMIZED_WITH_INSPECTOR_BUILD) src/main/cpp/com_tns_AndroidJsV8Inspector.cpp src/main/cpp/JsV8InspectorClient.cpp + src/main/cpp/WorkerInspectorClient.cpp src/main/cpp/v8_inspector/ns-v8-tracing-agent-impl.cpp src/main/cpp/v8_inspector/Utils.cpp ) diff --git a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp index 8be69189f..a573321df 100644 --- a/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp +++ b/test-app/runtime/src/main/cpp/JsV8InspectorClient.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,8 @@ #include "File.h" #include "Util.h" #include "Utils.h" +#include "WorkerInspectorClient.h" +#include "WorkerWrapper.h" #include "ada/ada.h" #include "third_party/json.hpp" @@ -38,20 +41,6 @@ static inline v8_inspector::StringView stringToStringView(const std::string &str return { chars, str.length() }; } -static inline std::string stringViewToString(v8::Isolate* isolate, const v8_inspector::StringView& stringView) { - int length = static_cast(stringView.length()); - if (!length) { - return ""; - } - v8::Local message = ( - stringView.is8Bit() ? - v8::String::NewFromOneByte(isolate, reinterpret_cast(stringView.characters8()), v8::NewStringType::kNormal, length) : - v8::String::NewFromTwoByte(isolate, reinterpret_cast(stringView.characters16()), v8::NewStringType::kNormal, length) - ) .ToLocalChecked(); - v8::String::Utf8Value result(isolate, message); - return *result; -} - namespace { // Scheme advertised to the frontend for source maps the runtime can serve. @@ -212,6 +201,7 @@ JsV8InspectorClient::JsV8InspectorClient(v8::Isolate* isolate) void JsV8InspectorClient::connect(jobject connection) { JEnv env; + std::lock_guard lock(connectionMutex_); connection_ = env.NewGlobalRef(connection); isConnected_ = true; } @@ -238,8 +228,29 @@ void JsV8InspectorClient::disconnect() { resourceStreams_.clear(); } - if (connection_ == nullptr) { - return; + // Reset worker sessions first and without the main-isolate Locker: if the + // main isolate is paused, its nested loop owns the Locker and this thread + // blocks below — workers must still get a clean slate (resume if paused, + // fresh session) so the reconnecting frontend can re-attach to them. + { + std::lock_guard lock(workerTargetsMutex_); + autoAttach_ = false; + for (auto& entry : workerTargets_) { + entry.second.announced = false; + entry.second.client->PushMessage(WorkerInspectorClient::kResetSessionMessage); + } + } + + { + std::lock_guard lock(connectionMutex_); + if (connection_ == nullptr) { + return; + } + + JEnv env; + env.DeleteGlobalRef(connection_); + connection_ = nullptr; + isConnected_ = false; } v8::Locker locker(isolate_); @@ -249,11 +260,6 @@ void JsV8InspectorClient::disconnect() { session_->resume(); session_.reset(); - JEnv env; - env.DeleteGlobalRef(connection_); - connection_ = nullptr; - isConnected_ = false; - createInspectorSession(); } @@ -390,11 +396,16 @@ void JsV8InspectorClient::doDispatchMessage(const std::string& message) { // returned stream with IO.read/IO.close. Neither domain is implemented by // V8's inspector, so they are served here, on the websocket read thread -- // the main thread may be blocked in the pause message loop or busy running -// JS. None of these handlers touch V8. -std::string JsV8InspectorClient::handleMessageOnSocketThread(const std::string& message) { +// JS. The Target domain and worker-session routing live here for the same +// reason: messages received while the main isolate is paused are dispatched +// by doDispatchMessage straight into the V8 session, so dispatchMessage's +// method handling never runs, and a worker must stay debuggable while the +// main isolate is paused (and vice versa). None of this touches V8 except +// the thread-safe RequestInterrupt. +bool JsV8InspectorClient::handleMessageOnSocketThread(const std::string& message, std::string& response) { auto parsed = json::parse(message, nullptr, false); if (parsed.is_discarded() || !parsed.is_object()) { - return ""; + return false; } std::string method = parsed.contains("method") && parsed["method"].is_string() @@ -407,12 +418,15 @@ std::string JsV8InspectorClient::handleMessageOnSocketThread(const std::string& ? parsed["sessionId"].get() : ""; + // Network/IO first: they serve any session (sessionId echoed), including + // worker source maps. if (method == "Network.loadNetworkResource") { std::string url; if (parsed.contains("params") && parsed["params"].contains("url") && parsed["params"]["url"].is_string()) { url = parsed["params"]["url"].get(); } - return HandleLoadNetworkResource(msgId, url, sessionId); + response = HandleLoadNetworkResource(msgId, url, sessionId); + return true; } if (method == "IO.read" || method == "IO.close") { @@ -427,9 +441,52 @@ std::string JsV8InspectorClient::handleMessageOnSocketThread(const std::string& size = params["size"].get(); } } - return method == "IO.read" - ? HandleIORead(msgId, handle, size, sessionId) - : HandleIOClose(msgId, handle, sessionId); + response = method == "IO.read" + ? HandleIORead(msgId, handle, size, sessionId) + : HandleIOClose(msgId, handle, sessionId); + return true; + } + + // DevTools discovers worker targets through the Target domain: its + // ChildTargetManager sends Target.setAutoAttach {flatten: true} right + // after connecting, and from then on expects Target.attachedToTarget / + // Target.detachedFromTarget events. + if (method == "Target.setAutoAttach") { + bool autoAttach = parsed.contains("params") && parsed["params"].contains("autoAttach") && + parsed["params"]["autoAttach"].is_boolean() && + parsed["params"]["autoAttach"].get(); + if (sessionId.empty()) { + { + std::lock_guard lock(workerTargetsMutex_); + autoAttach_ = autoAttach; + } + // The ack must reach the frontend before any attachedToTarget + // events, so it is sent here instead of through `response` (which + // the socket only writes after this method returns). + json ack = {{"id", msgId}, {"result", json::object()}}; + this->SendToFrontend(JsonDump(ack)); + if (autoAttach) { + this->AnnounceWorkerTargets(); + } + } else { + // Workers have no child targets of their own; just ack. + json ack = {{"id", msgId}, {"result", json::object()}}; + this->SendToFrontend(FinishResponse(ack, sessionId)); + } + return true; + } + + if (method == "Target.setDiscoverTargets" || method == "Target.setRemoteLocations" || + method == "Target.detachFromTarget") { + json ack = {{"id", msgId}, {"result", json::object()}}; + response = FinishResponse(ack, sessionId); + return true; + } + + // Flat-session protocol: a top-level sessionId addresses a worker target. + if (!sessionId.empty()) { + this->RouteToWorker(sessionId, method, msgId, message); + return true; } // Debugger.pause needs to interrupt V8 even if the main thread is busy @@ -449,7 +506,115 @@ std::string JsV8InspectorClient::handleMessageOnSocketThread(const std::string& this); } - return ""; + return false; +} + +void JsV8InspectorClient::RouteToWorker(const std::string& sessionId, const std::string& method, + long long msgId, const std::string& message) { + std::lock_guard lock(workerTargetsMutex_); + auto it = workerTargets_.find(sessionId); + if (it == workerTargets_.end()) { + // The worker died (or never existed): answer commands so the frontend + // does not wait forever. + if (msgId >= 0) { + json error = {{"id", msgId}, + {"sessionId", sessionId}, + {"error", {{"code", -32001}, {"message", "Session not found"}}}}; + this->SendToFrontend(JsonDump(error)); + } + return; + } + + WorkerInspectorClient* client = it->second.client; + + // Debugger.pause must interrupt a busy worker; skip it while the worker + // sits in its nested pause loop (no JS running - the interrupt would only + // fire after resume, causing a spurious re-pause). The message is still + // pushed so V8 answers the request id. + if (method == "Debugger.pause" && !client->IsRunningPauseLoop()) { + client->RequestPauseInterrupt(); + } + + client->PushMessage(message); +} + +void JsV8InspectorClient::RegisterWorkerTarget(int workerId, WorkerInspectorClient* client) { + std::lock_guard lock(workerTargetsMutex_); + WorkerTarget target{workerId, client, false}; + + if (isConnected_ && 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(JsonDump(attached)); + } + + workerTargets_.emplace(client->SessionId(), target); +} + +void JsV8InspectorClient::UnregisterWorkerTarget(int workerId) { + std::lock_guard lock(workerTargetsMutex_); + for (auto it = workerTargets_.begin(); it != workerTargets_.end(); ++it) { + if (it->second.workerId != workerId) { + continue; + } + + if (it->second.announced && isConnected_) { + json detached = {{"method", "Target.detachedFromTarget"}, + {"params", + {{"sessionId", it->second.client->SessionId()}, + {"targetId", it->second.client->TargetId()}}}}; + this->SendToFrontend(JsonDump(detached)); + } + + workerTargets_.erase(it); + return; + } +} + +void JsV8InspectorClient::AnnounceWorkerTargets() { + std::lock_guard lock(workerTargetsMutex_); + for (auto& entry : 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(JsonDump(attached)); + } +} + +void JsV8InspectorClient::SchedulePauseInWorker(int workerId) { + // Runs on the worker's own thread from a V8 interrupt; re-resolved through + // the registry so a late interrupt after the worker died is a no-op. + std::lock_guard lock(workerTargetsMutex_); + for (auto& entry : workerTargets_) { + if (entry.second.workerId == workerId) { + entry.second.client->SchedulePauseFromInterrupt(); + return; + } + } } std::string JsV8InspectorClient::HandleLoadNetworkResource(long long msgId, const std::string& url, const std::string& sessionId) { @@ -582,16 +747,33 @@ void JsV8InspectorClient::sendResponse(int callId, std::unique_ptr } void JsV8InspectorClient::sendNotification(std::unique_ptr message) { - if (connection_ == nullptr) { - return; - } - - const std::string msg = MaybeRewriteSourceMapURL(stringViewToString(isolate_, message->string())); + this->SendToFrontend(v8_inspector::toString16(message->string()).utf8()); +} +void JsV8InspectorClient::SendToFrontend(const std::string& message) { JEnv env; - // TODO: Pete: Check if we can use a wide (utf 16) string here - JniLocalRef str(env.NewStringUTF(msg.c_str())); - env.CallStaticVoidMethod(inspectorClass_, sendMethod_, connection_, (jstring) str); + JniLocalRef connection; + { + std::lock_guard lock(connectionMutex_); + if (connection_ == nullptr) { + return; + } + // A local ref keeps the websocket reachable after disconnect() drops + // the global ref; the (synchronized, possibly blocking) socket write + // must not run under the mutex or it would block disconnect(). + connection = JniLocalRef(env.NewLocalRef(connection_)); + } + + const std::string msg = MaybeRewriteSourceMapURL(message); + try { + // TODO: Pete: Check if we can use a wide (utf 16) string here + JniLocalRef str(env.NewStringUTF(msg.c_str())); + env.CallStaticVoidMethod(inspectorClass_, sendMethod_, (jobject) connection, (jstring) str); + } catch (NativeScriptException& e) { + // The socket died mid-send; the frontend is gone anyway. This must not + // unwind into V8 inspector internals or a worker's pause loop. + env.ExceptionClear(); + } } void JsV8InspectorClient::flushProtocolNotifications() { @@ -609,7 +791,11 @@ void JsV8InspectorClient::init() { inspector_ = V8Inspector::create(isolate_, this); - inspector_->contextCreated(v8_inspector::V8ContextInfo(context, JsV8InspectorClient::contextGroupId, {})); + // Name the context: the DevTools console context selector labels entries + // with the context's name; workers are labeled with their script url. + static const std::string mainContextName = "main"; + inspector_->contextCreated(v8_inspector::V8ContextInfo( + context, JsV8InspectorClient::contextGroupId, stringToStringView(mainContextName))); context_.Reset(isolate_, context); @@ -626,34 +812,40 @@ void JsV8InspectorClient::init() { } JsV8InspectorClient* JsV8InspectorClient::GetInstance() { - if (instance == nullptr) { - instance = new JsV8InspectorClient(Runtime::GetRuntime(0)->GetIsolate()); + JsV8InspectorClient* client = instance.load(std::memory_order_acquire); + if (client == nullptr) { + // Main-thread entry points only (JNI init/connect/dispatch); worker + // threads use GetInstanceIfCreated and never construct. + client = new JsV8InspectorClient(Runtime::GetRuntime(0)->GetIsolate()); + instance.store(client, std::memory_order_release); } - return instance; + return client; +} + +JsV8InspectorClient* JsV8InspectorClient::GetInstanceIfCreated() { + return instance.load(std::memory_order_acquire); } void JsV8InspectorClient::inspectorSendEventCallback(const FunctionCallbackInfo& args) { - if ((instance == nullptr) || (instance->connection_ == nullptr)) { + JsV8InspectorClient* client = GetInstanceIfCreated(); + if (client == nullptr || !client->isConnected_) { return; } - Isolate* isolate = args.GetIsolate(); Local arg = args[0].As(); std::string message = ArgConverter::ConvertToString(arg); - JEnv env; - // TODO: Pete: Check if we can use a wide (utf 16) string here - JniLocalRef str(env.NewStringUTF(message.c_str())); - env.CallStaticVoidMethod(instance->inspectorClass_, instance->sendMethod_, instance->connection_, (jstring) str); + client->SendToFrontend(message); // TODO: ios uses this method, but doesn't work on android // so I'm just sending directly to the socket (which seems to work) - instance->dispatchMessage(message); + client->dispatchMessage(message); } void JsV8InspectorClient::sendToFrontEndCallback(const v8::FunctionCallbackInfo& args) { - if ((instance == nullptr) || (instance->connection_ == nullptr)) { + JsV8InspectorClient* client = GetInstanceIfCreated(); + if (client == nullptr) { return; } @@ -669,9 +861,18 @@ void JsV8InspectorClient::sendToFrontEndCallback(const v8::FunctionCallbackInfo< } JEnv env; + JniLocalRef connection; + { + std::lock_guard lock(client->connectionMutex_); + if (client->connection_ == nullptr) { + return; + } + connection = JniLocalRef(env.NewLocalRef(client->connection_)); + } + JniLocalRef str(env.NewStringUTF(message.c_str())); JniLocalRef lev(env.NewStringUTF(level.c_str())); - env.CallStaticVoidMethod(instance->inspectorClass_, instance->sendToDevToolsConsoleMethod_, instance->connection_, (jstring) str, (jstring)lev); + env.CallStaticVoidMethod(client->inspectorClass_, client->sendToDevToolsConsoleMethod_, (jobject) connection, (jstring) str, (jstring)lev); } } catch (NativeScriptException& e) { e.ReThrowToV8(); @@ -687,25 +888,38 @@ void JsV8InspectorClient::sendToFrontEndCallback(const v8::FunctionCallbackInfo< } void JsV8InspectorClient::consoleLogCallback(Isolate* isolate, ConsoleAPIType method, const std::vector>& args) { - if (!inspectorIsConnected()) { + // Worker isolates route to their own inspector (we're on the worker's + // thread here). Checked first so worker logging never lazily constructs + // the root client. + WorkerWrapper* worker = WorkerWrapper::FromIsolate(isolate); + if (worker != nullptr) { + worker->ConsoleLog(method, args); + return; + } + + JsV8InspectorClient* client = GetInstanceIfCreated(); + if (client == nullptr || client->inspector_ == nullptr) { return; } // Note, here we access private V8 API - auto* impl = reinterpret_cast(instance->inspector_.get()); - auto* session = reinterpret_cast(instance->session_.get()); + auto* impl = reinterpret_cast(client->inspector_.get()); std::unique_ptr stack = impl->debugger()->captureStackTrace(false); - v8::Local context = instance->context_.Get(instance->isolate_); + v8::Local context = client->context_.Get(client->isolate_); const int contextId = V8ContextInfo::executionContextId(context); std::unique_ptr msg = v8_inspector::V8ConsoleMessage::createForConsoleAPI( - context, contextId, contextGroupId, impl, instance->currentTimeMS(), + context, contextId, contextGroupId, impl, client->currentTimeMS(), method, args, String16{}, std::move(stack)); - session->runtimeAgent()->messageAdded(msg.get()); + // 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, so anything logged before the frontend attaches + // shows up as console history. + impl->ensureConsoleMessageStorage(contextGroupId)->addMessage(std::move(msg)); } void JsV8InspectorClient::registerDomainDispatcherCallback(const FunctionCallbackInfo& args) { @@ -784,7 +998,7 @@ void JsV8InspectorClient::InspectorIsConnectedGetterCallback(v8::LocalisConnected_); } -JsV8InspectorClient* JsV8InspectorClient::instance = nullptr; +std::atomic JsV8InspectorClient::instance{nullptr}; bool JsV8InspectorClient::CallDomainHandlerFunction(Local context, Local domainMethodFunc, const Local& arg, Local& domainDebugger, Local& result) { diff --git a/test-app/runtime/src/main/cpp/JsV8InspectorClient.h b/test-app/runtime/src/main/cpp/JsV8InspectorClient.h index c7eac9bfe..b28257ba3 100644 --- a/test-app/runtime/src/main/cpp/JsV8InspectorClient.h +++ b/test-app/runtime/src/main/cpp/JsV8InspectorClient.h @@ -15,20 +15,38 @@ using namespace v8_inspector; namespace tns { +class WorkerInspectorClient; + class JsV8InspectorClient : V8InspectorClient, v8_inspector::V8Inspector::Channel { public: static JsV8InspectorClient* GetInstance(); + // Non-constructing variant for worker threads: returns nullptr until a + // main-thread entry point has created the client. + static JsV8InspectorClient* GetInstanceIfCreated(); + void init(); void connect(jobject connection); void scheduleBreak(); void disconnect(); void dispatchMessage(const std::string& message); - // Runs on the websocket read thread. Returns the JSON response to send - // directly on that socket, or an empty string when the message must - // flow through the normal dispatch queue. - std::string handleMessageOnSocketThread(const std::string& message); + // Any thread. Rewrites source map urls and writes to the frontend + // socket; serializes against connect/disconnect. + void SendToFrontend(const std::string& message); + + // Worker target management (Target domain, flat-session protocol). + // Register/Unregister run on the worker's own thread; SchedulePauseInWorker + // runs on the worker thread from a V8 interrupt. + void RegisterWorkerTarget(int workerId, WorkerInspectorClient* client); + void UnregisterWorkerTarget(int workerId); + void SchedulePauseInWorker(int workerId); + + // Runs on the websocket read thread. Returns true when the message was + // handled there (response, possibly empty when replies were already + // sent or none is needed, goes back to the socket); false routes the + // message through the normal main-thread dispatch queue. + bool handleMessageOnSocketThread(const std::string& message, std::string& response); void registerModules(); @@ -64,7 +82,7 @@ class JsV8InspectorClient : V8InspectorClient, v8_inspector::V8Inspector::Channe static void InspectorIsConnectedGetterCallback(v8::Local property, const v8::PropertyCallbackInfo& info); - static JsV8InspectorClient* instance; + static std::atomic instance; static constexpr int contextGroupId = 1; // Streams backing Network.loadNetworkResource responses, read by the @@ -75,6 +93,27 @@ class JsV8InspectorClient : V8InspectorClient, v8_inspector::V8Inspector::Channe size_t offset = 0; }; + // Live worker inspectors, keyed by their flat-protocol sessionId + // ("NS_WORKER_"). Entries are added/removed from worker threads and + // read from the socket thread. + struct WorkerTarget { + int workerId; + WorkerInspectorClient* client; + bool announced = false; + }; + + void RouteToWorker(const std::string& sessionId, const std::string& method, + long long msgId, const std::string& message); + void AnnounceWorkerTargets(); + + std::map workerTargets_; + std::mutex workerTargetsMutex_; + bool autoAttach_ = false; // guarded by workerTargetsMutex_ + + // Lock order: workerTargetsMutex_ -> connectionMutex_ (registry walks + // send while holding the registry lock); never the other way around. + std::mutex connectionMutex_; + // Source map delivery to Chrome DevTools (Network.loadNetworkResource + // IO domain). V8's inspector doesn't implement these embedder domains. std::string HandleLoadNetworkResource(long long msgId, const std::string& url, const std::string& sessionId); @@ -98,7 +137,10 @@ class JsV8InspectorClient : V8InspectorClient, v8_inspector::V8Inspector::Channe jobject connection_; bool running_nested_loop_ : 1; bool terminated_ : 1; - bool isConnected_ : 1; + // Read from worker threads (target announcements); must not share a + // memory location with the bitfields above, which the main thread + // writes. + std::atomic isConnected_; // {N} specific helpers diff --git a/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp b/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp new file mode 100644 index 000000000..26cefa5e1 --- /dev/null +++ b/test-app/runtime/src/main/cpp/WorkerInspectorClient.cpp @@ -0,0 +1,330 @@ +#include "WorkerInspectorClient.h" + +#include +#include + +#include + +#include +#include +#include +#include + +#include "JsV8InspectorClient.h" +#include "Runtime.h" + +using namespace v8; +using namespace v8_inspector; + +namespace tns { + +namespace { + +StringView Make8BitStringView(const std::string& value) { + return StringView(reinterpret_cast(value.data()), value.size()); +} + +// Pure C++ (no V8 handles): legal from the Channel callbacks regardless of +// what scopes the inspector entered before calling them. +std::string ToUtf8String(const StringView& view) { + return v8_inspector::toString16(view).utf8(); +} + +} // namespace + +WorkerInspectorClient::WorkerInspectorClient(int workerId, Isolate* isolate, ALooper* workerLooper, + const std::string& url) + : workerId_(workerId), + sessionId_("NS_WORKER_" + std::to_string(workerId)), + targetId_("ns-worker-" + std::to_string(workerId)), + url_(url), + isolate_(isolate), + workerLooper_(workerLooper) { + // Wakes the worker looper when CDP messages arrive on the socket thread; + // same mechanism as the worker's message inbox (ConcurrentQueue). + eventFd_ = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); + if (eventFd_ != -1 && workerLooper_ != nullptr && + ALooper_addFd(workerLooper_, eventFd_, ALOOPER_POLL_CALLBACK, ALOOPER_EVENT_INPUT, + WorkerInspectorClient::InspectorMessagesCallback, this) == 1) { + ALooper_acquire(workerLooper_); + } else { + if (eventFd_ != -1) { + close(eventFd_); + eventFd_ = -1; + } + workerLooper_ = nullptr; + } + + v8::Locker locker(isolate_); + Isolate::Scope isolate_scope(isolate_); + HandleScope handle_scope(isolate_); + Local context = Runtime::GetRuntime(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 (eventFd_ != -1) { + // Runs on the worker thread, so unregistering from its own looper is + // safe (same rule as ConcurrentQueue::Terminate). + ALooper_removeFd(workerLooper_, eventFd_); + close(eventFd_); + eventFd_ = -1; + ALooper_release(workerLooper_); + } + + v8::Locker locker(isolate_); + Isolate::Scope isolate_scope(isolate_); + HandleScope handle_scope(isolate_); + if (session_ != nullptr) { + session_->resume(); + session_.reset(); + } + inspector_.reset(); + context_.Reset(); +} + +int WorkerInspectorClient::InspectorMessagesCallback(int fd, int events, void* data) { + // Clear the eventfd counter first or the level-triggered looper spins. + uint64_t value; + read(fd, &value, sizeof(value)); + + static_cast(data)->DrainIncoming(); + return 1; +} + +void WorkerInspectorClient::PushMessage(const std::string& message) { + if (dying_) { + return; + } + + { + std::lock_guard lock(incomingMutex_); + incoming_.push(message); + } + + if (eventFd_ != -1) { + // Wakes an idle worker pumping its looper. While paused the looper is + // not pumping — the condition variable below wakes the nested pause + // loop instead, and the still-readable fd fires one (possibly empty) + // drain after resume. + uint64_t value = 1; + write(eventFd_, &value, sizeof(value)); + } + messageArrived_.notify_all(); +} + +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_.load(std::memory_order_acquire)) { + // 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 (eventFd_ != -1) { + uint64_t value = 1; + write(eventFd_, &value, sizeof(value)); + } + 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_.load(std::memory_order_acquire) && !dying_) { + pendingReset_ = false; + this->DoResetSession(); + } +} + +void WorkerInspectorClient::runMessageLoopOnPause(int contextGroupId) { + if (runningPauseLoop_.load(std::memory_order_acquire) || dying_) { + return; + } + runningPauseLoop_.store(true, std::memory_order_release); + pauseTerminated_ = false; + + while (!pauseTerminated_ && !dying_) { + std::string message = this->PopMessage(); + bool shouldWait = message.empty(); + if (!shouldWait) { + this->DispatchOne(message); + } + + while (v8::platform::PumpMessageLoop(Runtime::platform, isolate_)) { + } + + if (shouldWait && !pauseTerminated_ && !dying_) { + std::unique_lock lock(messageArrivedMutex_); + messageArrived_.wait_for(lock, std::chrono::milliseconds(1)); + } + } + + runningPauseLoop_.store(false, std::memory_order_release); +} + +void WorkerInspectorClient::quitMessageLoopOnPause() { + pauseTerminated_ = true; +} + +void WorkerInspectorClient::NotifyTerminating() { + dying_ = true; + pauseTerminated_ = true; + messageArrived_.notify_all(); +} + +void WorkerInspectorClient::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::GetInstanceIfCreated(); + 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(ToUtf8String(message->string())); +} + +void WorkerInspectorClient::sendNotification(std::unique_ptr message) { + this->SendWrapped(ToUtf8String(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::GetInstanceIfCreated(); + if (root != nullptr) { + root->SendToFrontend(wrapped); + } +} + +void WorkerInspectorClient::consoleLog(ConsoleAPIType method, + const std::vector>& args) { + if (inspector_ == nullptr) { + return; + } + + // Note, here we access private V8 API (mirrors + // JsV8InspectorClient::consoleLogCallback). + 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, this->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 tns diff --git a/test-app/runtime/src/main/cpp/WorkerInspectorClient.h b/test-app/runtime/src/main/cpp/WorkerInspectorClient.h new file mode 100644 index 000000000..a3deb572a --- /dev/null +++ b/test-app/runtime/src/main/cpp/WorkerInspectorClient.h @@ -0,0 +1,135 @@ +#ifndef WORKERINSPECTORCLIENT_H_ +#define WORKERINSPECTORCLIENT_H_ + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include "v8.h" +#include "v8-inspector.h" + +namespace tns { + +/* + * 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, + * mirroring the iOS runtime's WorkerInspectorClient. + * + * 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 an eventfd registered on the worker's + * ALooper (the same mechanism as the worker's message inbox); while paused, a + * nested loop on the worker thread pumps the same queue (the looper is NOT + * re-entered, so postMessage deliveries stay queued during a pause, matching + * Chrome's semantics). + */ +class WorkerInspectorClient final : public v8_inspector::V8InspectorClient, + public v8_inspector::V8Inspector::Channel { + public: + // Worker thread, with the worker isolate locked and its context created. + WorkerInspectorClient(int workerId, v8::Isolate* isolate, ALooper* workerLooper, + 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 looper / 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(); + + // Any thread. True while the worker sits in its nested pause loop — used + // by the root client to skip the pause interrupt (it would only fire + // after resume, causing a spurious re-pause). + bool IsRunningPauseLoop() const { + return runningPauseLoop_.load(std::memory_order_acquire); + } + + // Worker thread. Mirrors JsV8InspectorClient::consoleLogCallback for this + // isolate. + void consoleLog(v8_inspector::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; + + static int InspectorMessagesCallback(int fd, int events, void* data); + + 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_; + ALooper* workerLooper_; + int eventFd_ = -1; + + std::unique_ptr inspector_; + std::unique_ptr session_; + v8::Persistent context_; + + std::queue incoming_; + std::mutex incomingMutex_; + std::mutex messageArrivedMutex_; + std::condition_variable messageArrived_; + + std::atomic dying_{false}; + std::atomic pauseTerminated_{false}; + std::atomic runningPauseLoop_{false}; // written on the worker thread only + bool pendingReset_ = false; // worker thread only +}; + +} // namespace tns + +#endif /* WORKERINSPECTORCLIENT_H_ */ diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp index 2566c0d3a..d23c3d30e 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.cpp +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.cpp @@ -13,6 +13,11 @@ #include "NativeScriptException.h" #include "Runtime.h" +#ifdef APPLICATION_IN_DEBUG +#include "JsV8InspectorClient.h" +#include "WorkerInspectorClient.h" +#endif + using namespace v8; namespace tns { @@ -86,6 +91,18 @@ void WorkerWrapper::Terminate() { isolate->TerminateExecution(); } +#ifdef APPLICATION_IN_DEBUG + { + // A worker paused at a breakpoint sits in the inspector's nested pause + // loop, not in Looper.loop() - kick it loose so TerminateExecution and + // the looper quit below can take effect. + std::lock_guard lock(inspectorMutex_); + if (inspector_ != nullptr) { + inspector_->NotifyTerminating(); + } + } +#endif + QuitLooper(); } @@ -357,8 +374,13 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { auto context = runtime_->GetContext(); Context::Scope context_scope(context); - // (A future worker inspector would be created here, before the - // script runs, mirroring the iOS runtime.) +#ifdef APPLICATION_IN_DEBUG + // Expose this worker to an attached Chrome DevTools frontend + // as a child target, mirroring the iOS runtime. Created before + // the script runs so the worker's scripts are visible to the + // debugger from the start. + CreateInspector(isolate); +#endif if (!isTerminating_) { runtime_->RunWorker(workerPath_); @@ -403,6 +425,12 @@ void WorkerWrapper::BackgroundLooper(std::shared_ptr self) { } } +#ifdef APPLICATION_IN_DEBUG + // The inspector must be gone before the Runtime (and with it the isolate) + // is deleted below; unregistering also tells DevTools the target is gone. + DestroyInspector(); +#endif + // On this thread: safe to unregister the inbox fd from the looper. queue_.Terminate(); @@ -553,6 +581,63 @@ void WorkerWrapper::EnsureJniCached() { env.GetStaticMethodID(PROCESS_CLASS, "setThreadPriority", "(I)V"); } +#ifdef APPLICATION_IN_DEBUG +void WorkerWrapper::CreateInspector(Isolate* isolate) { + // Only when the root inspector client exists (debuggable app, created on + // the main thread during runtime init) - never construct it from here. + JsV8InspectorClient* root = JsV8InspectorClient::GetInstanceIfCreated(); + if (root == nullptr) { + return; + } + + // Same url scheme the module loader reports in Debugger.scriptParsed. + // workerPath_ may still be relative to the caller's dir at this point + // (resolution happens in require); callingDir_ ends with '/'. + std::string url = + "file://" + (workerPath_[0] == '/' ? workerPath_ : callingDir_ + workerPath_); + + auto* client = new WorkerInspectorClient(workerId_, isolate, ALooper_forThread(), url); + { + std::lock_guard lock(inspectorMutex_); + inspector_ = client; + } + + // Register only once fully constructed: registration makes the client + // reachable from the socket thread. + root->RegisterWorkerTarget(workerId_, client); +} + +void WorkerWrapper::DestroyInspector() { + WorkerInspectorClient* client = nullptr; + { + std::lock_guard lock(inspectorMutex_); + client = inspector_; + inspector_ = nullptr; + } + + if (client == nullptr) { + return; + } + + // Unregister first: after this returns no other thread can reach the + // client through the root's registry. + JsV8InspectorClient* root = JsV8InspectorClient::GetInstanceIfCreated(); + if (root != nullptr) { + root->UnregisterWorkerTarget(workerId_); + } + + delete client; +} + +void WorkerWrapper::ConsoleLog(v8_inspector::ConsoleAPIType method, + const std::vector>& args) { + std::lock_guard lock(inspectorMutex_); + if (inspector_ != nullptr) { + inspector_->consoleLog(method, args); + } +} +#endif + std::mutex WorkerWrapper::registryMutex_; std::map> WorkerWrapper::registry_; std::atomic_int WorkerWrapper::nextWorkerId_(0); diff --git a/test-app/runtime/src/main/cpp/WorkerWrapper.h b/test-app/runtime/src/main/cpp/WorkerWrapper.h index d37632386..464145d2f 100644 --- a/test-app/runtime/src/main/cpp/WorkerWrapper.h +++ b/test-app/runtime/src/main/cpp/WorkerWrapper.h @@ -9,6 +9,12 @@ #include #include +#ifdef APPLICATION_IN_DEBUG +#include + +#include +#endif + #include "ConcurrentQueue.h" #include "WorkerMessage.h" #include "v8.h" @@ -17,6 +23,9 @@ namespace tns { class LooperTasks; class Runtime; +#ifdef APPLICATION_IN_DEBUG +class WorkerInspectorClient; +#endif /* * Owns a worker's native thread and its lifecycle, mirroring the iOS @@ -121,6 +130,15 @@ class WorkerWrapper : public std::enable_shared_from_this { */ static void EnsureJniCached(); +#ifdef APPLICATION_IN_DEBUG + /* + * Worker thread (console.* fires on the isolate's own thread). Forwards + * to the worker's inspector so the log reaches DevTools' worker target. + */ + void ConsoleLog(v8_inspector::ConsoleAPIType method, + const std::vector>& args); +#endif + private: void BackgroundLooper(std::shared_ptr self); void DrainPendingTasks(); @@ -157,6 +175,19 @@ class WorkerWrapper : public std::enable_shared_from_this { std::mutex looperMutex_; jobject javaLooperRef_; +#ifdef APPLICATION_IN_DEBUG + /* + * Expose this worker to an attached Chrome DevTools frontend as a child + * target. Created on the worker thread before the script runs, destroyed + * on the worker thread before the isolate is disposed. + */ + void CreateInspector(v8::Isolate* isolate); + void DestroyInspector(); + + WorkerInspectorClient* inspector_ = nullptr; + std::mutex inspectorMutex_; +#endif + static std::mutex registryMutex_; static std::map> registry_; static std::atomic_int nextWorkerId_; diff --git a/test-app/runtime/src/main/cpp/com_tns_AndroidJsV8Inspector.cpp b/test-app/runtime/src/main/cpp/com_tns_AndroidJsV8Inspector.cpp index 21bd2a6ae..090a8db80 100644 --- a/test-app/runtime/src/main/cpp/com_tns_AndroidJsV8Inspector.cpp +++ b/test-app/runtime/src/main/cpp/com_tns_AndroidJsV8Inspector.cpp @@ -32,13 +32,15 @@ JNIEXPORT extern "C" void Java_com_tns_AndroidJsV8Inspector_dispatchMessage(JNIE JNIEXPORT extern "C" jstring Java_com_tns_AndroidJsV8Inspector_handleMessageOnSocketThread(JNIEnv* env, jobject instance, jstring jMessage) { try { std::string message = ArgConverter::jstringToString(jMessage); - std::string response = JsV8InspectorClient::GetInstance()->handleMessageOnSocketThread(message); - if (!response.empty()) { + std::string response; + if (JsV8InspectorClient::GetInstance()->handleMessageOnSocketThread(message, response)) { + // Handled; an empty string means any replies were already sent. return env->NewStringUTF(response.c_str()); } } catch (...) { // must never propagate a native exception into the websocket thread } + // Not handled -> Java queues it to the main-thread dispatcher. return nullptr; }