From 7af436c43d8bb7027c146e9a57bb19c3c89c5d3e Mon Sep 17 00:00:00 2001 From: Rider Linden Date: Mon, 17 Aug 2026 17:24:06 -0700 Subject: Issue https://github.com/secondlife/sl-vscode-plugin/issues/98: Refactor message passing into unified format. --- doc/external-editor-json-rpc.md | 38 ++- indra/llcorehttp/CMakeLists.txt | 1 + indra/llcorehttp/lljsonrpcws.cpp | 18 ++ indra/llcorehttp/lljsonrpcws.h | 5 + indra/llcorehttp/tests/llcorehttp_test.cpp | 1 + indra/llcorehttp/tests/test_jsonrpcws.hpp | 146 ++++++++++ indra/newview/llfloaterimnearbychathandler.cpp | 36 ++- indra/newview/llpreviewscript.cpp | 10 +- indra/newview/llpublishedobjectmgr.cpp | 381 ++++++++++++++++++++++++- indra/newview/llpublishedobjectmgr.h | 127 ++++++++- indra/newview/llscripteditorws.cpp | 376 +++++++++++------------- indra/newview/llscripteditorws.h | 41 +-- 12 files changed, 904 insertions(+), 276 deletions(-) create mode 100644 indra/llcorehttp/tests/test_jsonrpcws.hpp diff --git a/doc/external-editor-json-rpc.md b/doc/external-editor-json-rpc.md index 6c041cf70b..5c7d70185e 100644 --- a/doc/external-editor-json-rpc.md +++ b/doc/external-editor-json-rpc.md @@ -25,7 +25,7 @@ This document describes all the message interfaces defined for WebSocket communi - [ScriptUnsubscribe](#scriptunsubscribe) - [ScriptList](#scriptlist) - [Compilation Interfaces](#compilation-interfaces) - - [CompilationError](#compilationerror) + - [Diagnostic](#diagnostic) - [CompilationResult](#compilationresult) - [Runtime Event Interfaces](#runtime-event-interfaces) - [RuntimeDebug](#runtimedebug) @@ -601,12 +601,12 @@ interface ScriptList { ## Compilation Interfaces -### CompilationError +### Diagnostic Individual compilation error record. ```typescript -interface CompilationError { +interface Diagnostic { row: number; column: number; level: string; @@ -631,10 +631,11 @@ Result of a compilation operation in the viewer. ```typescript interface CompilationResult { + /** Deprecated: retained during migration to item-based routing. */ script_id: string; success: boolean; running: boolean; - errors?: CompilationError[]; + diagnostics?: Diagnostic[]; } ``` @@ -643,7 +644,7 @@ interface CompilationResult { - `script_id`: Unique identifier for the script that was compiled - `success`: Whether the compilation was successful - `running`: Whether the compiled script is currently running -- `errors` (optional): Array of compilation errors if any occurred +- `diagnostics` (optional): Array of `Diagnostic` records if any occurred ## Runtime Event Interfaces @@ -655,10 +656,15 @@ Debug message notification sent by the viewer during script execution. ```typescript interface RuntimeDebug { + /** Deprecated: use item.item_id when available. */ script_id: string; object_id: string; + prim_id?: string; + item_id?: string; object_name: string; message: string; + channel?: "debug" | "owner_say"; + item?: ItemRef; } ``` @@ -677,13 +683,27 @@ Runtime error notification sent by the viewer when a script encounters an error ```typescript interface RuntimeError { + /** Deprecated: use item.item_id when available. */ script_id: string; object_id: string; + prim_id?: string; + item_id?: string; object_name: string; message: string; error: string; line: number; + column?: number; stack?: string[]; + channel?: "debug" | "owner_say"; + item?: ItemRef; +} + +interface ItemRef { + root_id: string; + prim_id?: string; + item_id?: string; + name?: string; + language?: "lsl" | "luau"; } ``` @@ -693,8 +713,8 @@ interface RuntimeError { - `object_id`: Unique identifier for the object containing the script - `object_name`: Human-readable name of the object - `message`: The full raw chat text of the runtime error message as received from the simulator -- `error`: Extracted error description. Currently always an empty string - runtime error extraction from the simulator's multi-message format is not yet fully implemented. -- `line`: Line number where the error occurred. Currently always `0` for the same reason. +- `error`: Extracted runtime error description. This remains a top-level compatibility field while the protocol stays on version `1.0`. +- `line`: Line number where the error occurred when the runtime format can be parsed; otherwise `0`. - `stack` (optional): Stack trace lines if they could be extracted from the error message ## Handler and Configuration Interfaces @@ -973,7 +993,7 @@ interface ObjectContentSaveResponse { prim_id?: string; item_id?: string; compiled?: boolean; - errors?: string[]; + diagnostics?: Diagnostic[]; message?: string; } ``` @@ -986,7 +1006,7 @@ interface ObjectContentSaveResponse { - `vm` (optional): Scripts only compile target. Accepted values are `"mono"`, `"lsl2"`, `"luau"`. When `"luau"` is specified for an LSL script (as opposed to a native Luau script), the viewer automatically selects the correct LSL-on-Luau compile path. If omitted, inferred from item metadata or content analysis. - `success`: Whether the upload/save operation succeeded. - `compiled` (optional): Scripts only. `true` when compilation succeeded, `false` when source saved but compile failed. -- `errors` (optional): Scripts only. Compiler diagnostics when `compiled` is `false`. +- `diagnostics` (optional): Scripts only. Array of `Diagnostic` records when `compiled` is `false`. - `message` (optional): Error description on failure. --- diff --git a/indra/llcorehttp/CMakeLists.txt b/indra/llcorehttp/CMakeLists.txt index fbc25a7cd5..c2c4078ff0 100644 --- a/indra/llcorehttp/CMakeLists.txt +++ b/indra/llcorehttp/CMakeLists.txt @@ -119,6 +119,7 @@ if (LL_TESTS AND LLCOREHTTP_TESTS) tests/test_httpheaders.hpp tests/test_bufferarray.hpp tests/test_bufferstream.hpp + tests/test_jsonrpcws.hpp ) list(APPEND llcorehttp_TEST_SOURCE_FILES ${llcorehttp_TEST_HEADER_FILES}) diff --git a/indra/llcorehttp/lljsonrpcws.cpp b/indra/llcorehttp/lljsonrpcws.cpp index 3a0d3d1f26..d6c5b0eb97 100644 --- a/indra/llcorehttp/lljsonrpcws.cpp +++ b/indra/llcorehttp/lljsonrpcws.cpp @@ -457,6 +457,24 @@ void LLJSONRPCConnection::sweepTimeouts() } } +void LLJSONRPCConnection::testInjectPendingRequest(const std::string& id, F64 deadline, ResponseCallback callback) +{ + LLMutexLock lock(&mMutex); + mPendingRequests[id] = std::move(callback); + mPendingDeadlines.push({ deadline, id }); +} + +void LLJSONRPCConnection::testSweepTimeouts() +{ + sweepTimeouts(); +} + +size_t LLJSONRPCConnection::testPendingRequestCount() const +{ + LLMutexLock lock(&mMutex); + return mPendingRequests.size(); +} + LLSD LLJSONRPCConnection::generateId() { // Server-wide atomic counter for efficient unique ID generation. diff --git a/indra/llcorehttp/lljsonrpcws.h b/indra/llcorehttp/lljsonrpcws.h index cd71473a47..7cb26b1fb2 100644 --- a/indra/llcorehttp/lljsonrpcws.h +++ b/indra/llcorehttp/lljsonrpcws.h @@ -401,6 +401,11 @@ private: /// Invoked by the sweep timer; fires the timeout callback for any /// request whose deadline has passed. Safe to call from the main thread. void sweepTimeouts(); + +public: + void testInjectPendingRequest(const std::string& id, F64 deadline, ResponseCallback callback); + void testSweepTimeouts(); + size_t testPendingRequestCount() const; }; /** diff --git a/indra/llcorehttp/tests/llcorehttp_test.cpp b/indra/llcorehttp/tests/llcorehttp_test.cpp index c7c50e6166..65d7fe93a9 100644 --- a/indra/llcorehttp/tests/llcorehttp_test.cpp +++ b/indra/llcorehttp/tests/llcorehttp_test.cpp @@ -44,6 +44,7 @@ #include "test_httprequest.hpp" #include "test_httpheaders.hpp" #include "test_httprequestqueue.hpp" +#include "test_jsonrpcws.hpp" #include "_httpservice.h" #include "llproxy.h" diff --git a/indra/llcorehttp/tests/test_jsonrpcws.hpp b/indra/llcorehttp/tests/test_jsonrpcws.hpp new file mode 100644 index 0000000000..6cf7932aa0 --- /dev/null +++ b/indra/llcorehttp/tests/test_jsonrpcws.hpp @@ -0,0 +1,146 @@ +/** + * @file test_jsonrpcws.hpp + * @brief unit tests for LLJSONRPCConnection helpers and dispatch behavior + */ + +#ifndef TEST_LLCORE_JSONRPCWS_H_ +#define TEST_LLCORE_JSONRPCWS_H_ + +#include "lljsonrpcws.h" +#include "lltimer.h" + +namespace +{ +class TestJSONRPCConnection : public LLJSONRPCConnection +{ +public: + TestJSONRPCConnection() + : LLJSONRPCConnection(LLWebsocketMgr::WSServer::ptr_t(), LLWebsocketMgr::connection_h()) + { + } + + using LLJSONRPCConnection::processMessage; + using LLJSONRPCConnection::validateMessage; + using LLJSONRPCConnection::testInjectPendingRequest; + using LLJSONRPCConnection::testPendingRequestCount; + using LLJSONRPCConnection::testSweepTimeouts; +}; +} + +namespace tut +{ + struct JSONRPCWSTestData + { + }; + + typedef test_group JSONRPCWSTestGroupType; + typedef JSONRPCWSTestGroupType::object JSONRPCWSTestObjectType; + JSONRPCWSTestGroupType JSONRPCWSTestGroup("LLJSONRPCConnection Tests"); + + template<> template<> + void JSONRPCWSTestObjectType::test<1>() + { + set_test_name("makeEnvelope notification omits id"); + + LLSD params; + params["value"] = 42; + LLSD env = LLJSONRPCConnection::makeEnvelope(LLSD(), "runtime.debug", params, LLSD(), LLSD()); + + ensure("jsonrpc field should exist", env.has("jsonrpc")); + ensure_equals("jsonrpc version", env["jsonrpc"].asString(), "2.0"); + ensure("notification should omit id", !env.has("id")); + ensure_equals("method", env["method"].asString(), "runtime.debug"); + ensure_equals("param round trip", env["params"]["value"].asInteger(), 42); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<2>() + { + set_test_name("makeEnvelope response keeps id slot"); + + LLSD env = LLJSONRPCConnection::makeEnvelope(LLSD(), std::string(), LLSD(), LLSD("ok"), LLSD()); + + ensure("response should include id", env.has("id")); + ensure("response should not include method", !env.has("method")); + ensure_equals("result", env["result"].asString(), "ok"); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<3>() + { + set_test_name("validateMessage accepts valid request and rejects invalid params"); + + TestJSONRPCConnection conn; + + LLSD valid; + valid["jsonrpc"] = "2.0"; + valid["method"] = "session.ping"; + LLSD params = LLSD::emptyMap(); + params["timestamp"] = 123; + valid["params"] = params; + ensure("valid request should pass", conn.validateMessage(valid, true)); + + LLSD invalid = valid; + invalid["params"] = "not-an-array-or-object"; + ensure("invalid params type should fail", !conn.validateMessage(invalid, true)); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<4>() + { + set_test_name("processMessage dispatches notification handler"); + + TestJSONRPCConnection conn; + + bool called = false; + LLSD seen_id; + LLSD seen_params; + conn.registerMethod("runtime.debug", + [&](const std::string& method, const LLSD& id, const LLSD& params) -> LLSD + { + called = true; + ensure_equals("method propagated", method, "runtime.debug"); + seen_id = id; + seen_params = params; + return LLSD(); + }); + + LLSD params; + params["message"] = "hello"; + LLSD notification = LLJSONRPCConnection::makeEnvelope(LLSD(), "runtime.debug", params, LLSD(), LLSD()); + conn.processMessage(notification); + + ensure("handler should be called", called); + ensure("notification id should be undefined", seen_id.isUndefined()); + ensure_equals("payload should be forwarded", seen_params["message"].asString(), "hello"); + } + + template<> template<> + void JSONRPCWSTestObjectType::test<5>() + { + set_test_name("sweepTimeouts expires overdue callbacks"); + + TestJSONRPCConnection conn; + + bool callback_called = false; + conn.testInjectPendingRequest( + "req_1", + LLTimer::getTotalSeconds() - 1.0, + [&](const LLSD& result, const LLSD& error) + { + callback_called = true; + ensure("timed out result should be undefined", result.isUndefined()); + ensure_equals( + "timeout code", + error["code"].asInteger(), + LLJSONRPCConnection::RPCError::REQUEST_TIMEOUT); + }); + + ensure_equals("pending request should be tracked", conn.testPendingRequestCount(), (size_t)1); + conn.testSweepTimeouts(); + ensure("timeout callback should be called", callback_called); + ensure_equals("pending request should be removed", conn.testPendingRequestCount(), (size_t)0); + } +} + +#endif \ No newline at end of file diff --git a/indra/newview/llfloaterimnearbychathandler.cpp b/indra/newview/llfloaterimnearbychathandler.cpp index e92672f684..34570725ce 100644 --- a/indra/newview/llfloaterimnearbychathandler.cpp +++ b/indra/newview/llfloaterimnearbychathandler.cpp @@ -564,6 +564,23 @@ void LLFloaterIMNearbyChatHandler::processChat(const LLChat& chat_msg, return; } + if (LLScriptEditorWSServer::isEnabled() && + gSavedSettings.getBOOL("ExternalWebsocketForwardDebug") && + (chat_msg.mChatType == CHAT_TYPE_DEBUG_MSG || + chat_msg.mChatType == CHAT_TYPE_OWNER)) + { + LLScriptEditorWSServer::ptr_t server = + LLScriptEditorWSServer::getServer(); + if (server) + { + const auto channel = + chat_msg.mChatType == CHAT_TYPE_OWNER + ? LLPublishedObjectMgr::RuntimeEventAggregator::Channel::OWNER_SAY + : LLPublishedObjectMgr::RuntimeEventAggregator::Channel::DEBUG; + server->forwardChatToIDE(chat_msg, channel); + } + } + // don't show toast and add message to chat history on receive debug message // with disabled setting showing script errors or enabled setting to show script // errors in separate window. @@ -575,15 +592,6 @@ void LLFloaterIMNearbyChatHandler::processChat(const LLChat& chat_msg, if (!gSavedSettings.getBOOL("ShowScriptErrors")) return; - if (LLScriptEditorWSServer::isEnabled() && gSavedSettings.getBOOL("ExternalWebsocketForwardDebug")) - { - LLScriptEditorWSServer::ptr_t server = LLScriptEditorWSServer::getServer(); - if (server) - { - server->forwardChatToIDE(chat_msg); - } - } - // don't process debug messages from not owned objects, see EXT-7762 if (gAgentID != chat_msg.mOwnerID) { @@ -603,16 +611,6 @@ void LLFloaterIMNearbyChatHandler::processChat(const LLChat& chat_msg, return; } } - else if ((chat_msg.mChatType == CHAT_TYPE_OWNER) && LLScriptEditorWSServer::isEnabled() && - gSavedSettings.getBOOL("ExternalWebsocketForwardDebug")) - { - LLScriptEditorWSServer::ptr_t server = LLScriptEditorWSServer::getServer(); - if (server) - { - server->forwardChatToIDE(chat_msg); - } - } - nearby_chat->addMessage(chat_msg, true, args); if (chat_msg.mSourceType == CHAT_SOURCE_AGENT diff --git a/indra/newview/llpreviewscript.cpp b/indra/newview/llpreviewscript.cpp index 10bacf53b7..354147336a 100644 --- a/indra/newview/llpreviewscript.cpp +++ b/indra/newview/llpreviewscript.cpp @@ -40,7 +40,6 @@ #include "llinventorymodel.h" #include "llkeyboard.h" #include "lllineeditor.h" -#include "llmd5.h" #include "llhelp.h" #include "llnotificationsutil.h" #include "llresmgr.h" @@ -1613,14 +1612,7 @@ std::string LLScriptEdContainer::getUniqueHash() const // Take script inventory item id (within the object inventory) // to consideration so that it's possible to edit multiple scripts // in the same object inventory simultaneously (STORM-781). - std::string script_id = mObjectUUID.asString() + "_" + mItemUUID.asString(); - - // Use MD5 sum to make the file name shorter and not exceed maximum path length. - char script_id_hash_str[33]; /* Flawfinder: ignore */ - LLMD5 script_id_hash((const U8*)script_id.c_str()); - script_id_hash.hex_digest(script_id_hash_str); - - return std::string(script_id_hash_str); + return LLScriptEditorWSServer::buildScriptSubscriptionId(mObjectUUID, mItemUUID); } std::string LLScriptEdContainer::getErrorLogFileName(const std::string& script_path) diff --git a/indra/newview/llpublishedobjectmgr.cpp b/indra/newview/llpublishedobjectmgr.cpp index e9d13c1213..d92bb382de 100644 --- a/indra/newview/llpublishedobjectmgr.cpp +++ b/indra/newview/llpublishedobjectmgr.cpp @@ -29,7 +29,9 @@ #include "llscripteditorws.h" +#include "llchat.h" #include "llinventorydefines.h" +#include "llregex.h" #include "llselectmgr.h" #include "llviewerinventory.h" #include "llviewerobject.h" @@ -39,6 +41,12 @@ namespace { + static const boost::regex LUAU_LOCATION_PATTERN( + R"(^([^:]*):([0-9]+):\s*(.*)$)"); + + static const boost::regex LSL_LOCATION_PATTERN( + R"(\((\d+), (\d+)\) : ([^:]+) : (.+))"); + std::string nv_string(LLViewerObject* obj, const char* key) { if (!obj) @@ -122,13 +130,382 @@ private: LLUUID mPrimID; }; -LLPublishedObjectMgr::LLPublishedObjectMgr(LLScriptEditorWSServer* server) - : mServer(server) +LLPublishedObjectMgr::LLPublishedObjectMgr( + LLScriptEditorWSServer* server, + RuntimeEventCallback runtime_event_callback): + mServer(server), + mRuntimeEventCallback(std::move(runtime_event_callback)), + mRuntimeEventAggregator(std::make_unique( + [this](const RuntimeEventAggregator::RuntimeEvent& runtime_event) + { + std::optional event = + buildRuntimeChatEvent(runtime_event); + if (event && mRuntimeEventCallback) + { + mRuntimeEventCallback(*event); + } + })), + mRuntimeFlushTimer( + std::unique_ptr( + LLEventTimer::run_every( + RUNTIME_FLUSH_INTERVAL, + [this]() + { + flushExpiredRuntimeFragments(); + }))) { } LLPublishedObjectMgr::~LLPublishedObjectMgr() = default; +void LLPublishedObjectMgr::ingestRuntimeChat( + const LLChat& chat_msg, + RuntimeEventAggregator::Channel channel) +{ + static const boost::regex runtime_error_header( + R"(^(.+?)\s+\[script:([^\]]+)\]\s+Script run-time error)"); + + std::vector lines = + LLStringUtil::getTokens(chat_msg.mText, "\n"); + boost::smatch match; + bool is_error_header = + !lines.empty() && + boost::regex_match(lines.front(), match, runtime_error_header); + + if (!is_error_header && !mRuntimeEventAggregator->hasPending()) + { + RuntimeEventAggregator::RuntimeEvent runtime_event; + runtime_event.mVM = RuntimeEventAggregator::VM::LSL2; + runtime_event.mChannel = channel; + + RuntimeEventAggregator::Fragment fragment; + fragment.mFromID = chat_msg.mFromID; + fragment.mFromName = chat_msg.mFromName; + fragment.mText = chat_msg.mText; + runtime_event.mFragments.push_back(std::move(fragment)); + + std::optional event = + buildRuntimeChatEvent(runtime_event); + if (event && mRuntimeEventCallback) + { + mRuntimeEventCallback(*event); + } + return; + } + + RuntimeEventAggregator::VM vm = RuntimeEventAggregator::VM::LSL2; + LLViewerObject* prim = gObjectList.findObject(chat_msg.mFromID); + if (prim) + { + std::vector lines = + LLStringUtil::getTokens(chat_msg.mText, "\n"); + static const boost::regex runtime_error_header( + R"(^(.+?)\s+\[script:([^\]]+)\]\s+Script run-time error)"); + boost::smatch match; + + if (!lines.empty() && + boost::regex_match(lines.front(), match, runtime_error_header)) + { + const std::string script_name = match[2].str(); + LLInventoryObject::object_list_t inventory; + prim->getInventoryContents(inventory); + for (const auto& inventory_object : inventory) + { + LLInventoryItem* item = + dynamic_cast(inventory_object.get()); + if (item && item->getName() == script_name && + item->getRuntime() == "luau") + { + vm = RuntimeEventAggregator::VM::LUAU; + break; + } + } + } + } + + mRuntimeEventAggregator->ingest( + chat_msg.mFromID, + chat_msg.mFromName, + chat_msg.mText, + vm, + channel); +} + +void LLPublishedObjectMgr::flushExpiredRuntimeFragments() +{ + mRuntimeEventAggregator->flushExpired(); +} + +std::optional +LLPublishedObjectMgr::buildRuntimeChatEvent( + const RuntimeEventAggregator::RuntimeEvent& runtime_event) const +{ + if (runtime_event.mFragments.empty()) + { + return std::nullopt; + } + + const RuntimeEventAggregator::Fragment& source = + runtime_event.mFragments.front(); + LLViewerObject* prim = gObjectList.findObject(source.mFromID); + if (!prim) + { + return std::nullopt; + } + + LLViewerObject* root = prim->getRootEdit(); + if (!root) + { + return std::nullopt; + } + + RuntimeChatEvent event; + event.mChannel = runtime_event.mChannel; + event.mVM = runtime_event.mVM; + event.mRootID = root->getID(); + event.mPrimID = prim->getID(); + event.mObjectName = source.mFromName; + for (const auto& fragment : runtime_event.mFragments) + { + if (!event.mMessage.empty()) + { + event.mMessage += "\n"; + } + event.mMessage += fragment.mText; + } + + std::vector lines = + LLStringUtil::getTokens(event.mMessage, "\n"); + static const std::string runtime_error_marker = "Script run-time error"; + static const boost::regex runtime_error_header( + R"(^(.+?)\s+\[script:([^\]]+)\]\s+Script run-time error)"); + + auto ends_with = [](const std::string& value, const std::string& suffix) + { + return value.size() >= suffix.size() && + std::equal(suffix.rbegin(), suffix.rend(), value.rbegin()); + }; + + if (!lines.empty() && ends_with(lines.front(), runtime_error_marker)) + { + event.mIsError = true; + boost::smatch match; + if (boost::regex_match(lines.front(), match, runtime_error_header)) + { + event.mObjectName = match[1].str(); + event.mScriptName = match[2].str(); + lines.erase(lines.begin()); + } + else + { + lines.clear(); + } + } + + LLInventoryObject::object_list_t inventory; + prim->getInventoryContents(inventory); + for (const auto& inventory_object : inventory) + { + LLInventoryItem* item = + dynamic_cast(inventory_object.get()); + if (item && !event.mScriptName.empty() && + item->getName() == event.mScriptName) + { + event.mItemID = item->getUUID(); + break; + } + } + + if (event.mIsError && !lines.empty()) + { + RuntimeEventAggregator::VM vm = runtime_event.mVM; + if (event.mItemID.notNull()) + { + LLInventoryItem* item = + dynamic_cast(prim->getInventoryObject(event.mItemID)); + if (item && item->getRuntime() == "luau") + { + vm = RuntimeEventAggregator::VM::LUAU; + } + } + + RuntimeEventAggregator::ParsedError parsed = + mRuntimeEventAggregator->parseError(vm, runtime_event.mFragments); + event.mError = parsed.mError; + event.mLine = parsed.mLine; + event.mColumn = parsed.mColumn; + event.mStack = lines; + } + + return event; +} + +LLPublishedObjectMgr::RuntimeEventAggregator::RuntimeEventAggregator( + FlushCallback flush_callback): + mFlushCallback(std::move(flush_callback)) +{ +} + +void LLPublishedObjectMgr::RuntimeEventAggregator::ingest( + const LLUUID& from_id, + const std::string& from_name, + const std::string& text, + VM vm, + Channel channel) +{ + if (mPending && + isNewBurst(from_id, from_name, vm, channel)) + { + flushPending(); + } + + if (!mPending) + { + mPending = std::make_unique(); + mPending->mFromID = from_id; + mPending->mFromName = from_name; + mPending->mVM = vm; + mPending->mChannel = channel; + } + + Fragment fragment; + fragment.mFromID = from_id; + fragment.mFromName = from_name; + fragment.mText = text; + mPending->mFragments.push_back(std::move(fragment)); + mPending->mTimer.setTimerExpirySec(FRAGMENT_TIMEOUT); +} + +void LLPublishedObjectMgr::RuntimeEventAggregator::flushExpired() +{ + if (mPending && mPending->mTimer.hasExpired()) + { + flushPending(); + } +} + +void LLPublishedObjectMgr::RuntimeEventAggregator::flush() +{ + flushPending(); +} + +bool LLPublishedObjectMgr::RuntimeEventAggregator::hasPending() const +{ + return mPending != nullptr; +} + +LLPublishedObjectMgr::RuntimeEventAggregator::ParsedError +LLPublishedObjectMgr::RuntimeEventAggregator::parseError( + VM vm, + const fragments_t& fragments) const +{ + ParsedError parsed; + const boost::regex* location_pattern = nullptr; + bool lsl_coordinates = false; + + switch (vm) + { + case VM::LUAU: + location_pattern = &LUAU_LOCATION_PATTERN; + break; + case VM::LSL2: + location_pattern = &LSL_LOCATION_PATTERN; + lsl_coordinates = true; + break; + } + + for (const auto& fragment : fragments) + { + std::vector lines = + LLStringUtil::getTokens(fragment.mText, "\n"); + for (const auto& line : lines) + { + boost::smatch match; + if (!boost::regex_match(line, match, *location_pattern)) + { + continue; + } + + if (lsl_coordinates) + { + parsed.mLine = static_cast( + std::strtol(match[1].str().c_str(), nullptr, 10)) + 1; + parsed.mColumn = static_cast( + std::strtol(match[2].str().c_str(), nullptr, 10)) + 1; + parsed.mError = match[4].str(); + } + else + { + parsed.mSource = match[1].str(); + parsed.mLine = static_cast( + std::strtol(match[2].str().c_str(), nullptr, 10)); + parsed.mColumn = 0; + parsed.mError = match[3].str(); + } + return parsed; + } + } + + // LSL runtime errors commonly arrive as plain text without source + // location metadata, for example: "Math Error". + if (vm == VM::LSL2) + { + for (const auto& fragment : fragments) + { + const std::vector lines = + LLStringUtil::getTokens(fragment.mText, "\n"); + for (const auto& line : lines) + { + if (line.empty() || + line.find("Script run-time error") != std::string::npos) + { + continue; + } + + parsed.mError = line; + return parsed; + } + } + } + + return parsed; +} + +void LLPublishedObjectMgr::RuntimeEventAggregator::flushPending() +{ + if (!mPending) + { + return; + } + + if (!mFlushCallback) + { + mPending.reset(); + return; + } + + RuntimeEvent event; + event.mVM = mPending->mVM; + event.mChannel = mPending->mChannel; + event.mFragments = mPending->mFragments; + event.mError = parseError(mPending->mVM, mPending->mFragments); + mFlushCallback(event); + + mPending.reset(); +} + +bool LLPublishedObjectMgr::RuntimeEventAggregator::isNewBurst( + const LLUUID& from_id, + const std::string& from_name, + VM vm, + Channel channel) const +{ + return mPending->mFromID != from_id || + mPending->mFromName != from_name || + mPending->mVM != vm || + mPending->mChannel != channel; +} + LLPublishedObjectMgr::PublishedObjectInfo::PublishedObjectInfo() = default; LLPublishedObjectMgr::PublishedObjectInfo::~PublishedObjectInfo() = default; LLPublishedObjectMgr::PublishedObjectInfo::PublishedObjectInfo(PublishedObjectInfo&&) noexcept = default; diff --git a/indra/newview/llpublishedobjectmgr.h b/indra/newview/llpublishedobjectmgr.h index 99ffa34b39..51af129dcb 100644 --- a/indra/newview/llpublishedobjectmgr.h +++ b/indra/newview/llpublishedobjectmgr.h @@ -27,17 +27,21 @@ #pragma once #include "llsd.h" +#include "lltimer.h" #include "lluuid.h" #include "lleventtimer.h" #include "stdtypes.h" #include +#include #include +#include #include #include #include class LLScriptEditorWSServer; +class LLChat; class LLPublishedPrimListener; class LLViewerObject; @@ -117,9 +121,124 @@ public: std::string mPendingItemCreatePump; }; - explicit LLPublishedObjectMgr(LLScriptEditorWSServer* server = nullptr); + class RuntimeEventAggregator + { + public: + enum class Channel + { + DEBUG, + OWNER_SAY + }; + + enum class VM + { + LSL2, + LUAU + }; + + struct StackFrame + { + S32 mLine{ 0 }; + std::string mFunction; + std::string mSource; + }; + + struct ParsedError + { + std::string mSource; + std::string mError; + S32 mLine{ 0 }; + S32 mColumn{ 0 }; + std::vector mStack; + }; + + struct Fragment + { + LLUUID mFromID; + std::string mFromName; + std::string mText; + }; + + using fragments_t = std::vector; + + struct RuntimeEvent + { + VM mVM; + Channel mChannel; + fragments_t mFragments; + ParsedError mError; + }; + + using FlushCallback = std::function; + + explicit RuntimeEventAggregator(FlushCallback flush_callback); + + void ingest(const LLUUID& from_id, + const std::string& from_name, + const std::string& text, + VM vm, + Channel channel); + void flushExpired(); + void flush(); + ParsedError parseError(VM vm, + const fragments_t& fragments) const; + bool hasPending() const; + + private: + struct PendingBurst + { + LLUUID mFromID; + std::string mFromName; + VM mVM{ VM::LSL2 }; + Channel mChannel{ Channel::DEBUG }; + fragments_t mFragments; + LLTimer mTimer; + }; + + static constexpr F32 FRAGMENT_TIMEOUT = 1.0f; + + void flushPending(); + bool isNewBurst(const LLUUID& from_id, + const std::string& from_name, + VM vm, + Channel channel) const; + + FlushCallback mFlushCallback; + std::unique_ptr mPending; + }; + + struct RuntimeChatEvent + { + RuntimeEventAggregator::Channel mChannel; + RuntimeEventAggregator::VM mVM; + LLUUID mRootID; + LLUUID mPrimID; + LLUUID mItemID; + std::string mObjectName; + std::string mScriptName; + std::string mMessage; + std::string mError; + S32 mLine{ 0 }; + S32 mColumn{ 0 }; + std::vector mStack; + bool mIsError{ false }; + }; + + using RuntimeEventCallback = + std::function; + + explicit LLPublishedObjectMgr( + LLScriptEditorWSServer* server = nullptr, + RuntimeEventCallback runtime_event_callback = {}); ~LLPublishedObjectMgr(); + void flushExpiredRuntimeFragments(); + void ingestRuntimeChat( + const LLChat& chat_msg, + RuntimeEventAggregator::Channel channel); + std::optional buildRuntimeChatEvent( + const RuntimeEventAggregator::RuntimeEvent& event) const; + bool hasPublished(const LLUUID& object_id) const { return mPublishedObjects.find(object_id) != mPublishedObjects.end(); } void erasePublished(const LLUUID& object_id) { mPublishedObjects.erase(object_id); } PublishedObjectInfo* getPublished(const LLUUID& object_id); @@ -190,6 +309,12 @@ public: private: LLScriptEditorWSServer* mServer{ nullptr }; + std::unique_ptr mRuntimeEventAggregator; + RuntimeEventCallback mRuntimeEventCallback; + std::unique_ptr mRuntimeFlushTimer; + + static constexpr F32 RUNTIME_FLUSH_INTERVAL = 0.25f; + void cancelPendingPublishWithCleanup(const LLUUID& object_id); void clearPublishedListeners(const LLUUID& object_id); diff --git a/indra/newview/llscripteditorws.cpp b/indra/newview/llscripteditorws.cpp index 0487ebd06d..e33e18bcd4 100644 --- a/indra/newview/llscripteditorws.cpp +++ b/indra/newview/llscripteditorws.cpp @@ -50,6 +50,7 @@ #include "llpreviewscript.h" #include "llprocess.h" #include "llregex.h" +#include "llmd5.h" #include "llsdjson.h" #include "llselectmgr.h" #include "lltrans.h" @@ -68,6 +69,8 @@ #include "llvoinventorylistener.h" #include "roles_constants.h" +#include + namespace { // Per-operation timeouts (seconds) for coroutine-based async RPC handlers. @@ -80,6 +83,12 @@ namespace constexpr F32 LINKSET_ADD_FLUSH_DELAY = 5.0f; constexpr F32 LINKSET_REMOVE_FLUSH_DELAY = 0.2f; + static const boost::regex LUAU_LOCATION_PATTERN( + R"(^([^:]*):([0-9]+):\s*(.*)$)"); + + static const boost::regex LSL_LOCATION_PATTERN( + R"(\((\d+), (\d+)\) : ([^:]+) : (.+))"); + // Creates a uniquely-named LLEventMailDrop under ".", passes // its name to kickoff (which arranges for one post to that pump), then // suspends the current coroutine up to imeout seconds for the result. @@ -165,7 +174,12 @@ namespace //======================================================================== LLScriptEditorWSServer::LLScriptEditorWSServer(const std::string& name, U16 port, bool local_only): LLJSONRPCServer(name, port, local_only), - mPublishedObjectManager(this) + mPublishedObjectManager( + this, + [this](const LLPublishedObjectMgr::RuntimeChatEvent& event) + { + sendRuntimeEvent(event); + }) { LL_INFOS("ScriptEditorWS") << "Created JSON-RPC script editor server: " << name << " on port " << port << LL_ENDL; @@ -198,11 +212,8 @@ LLScriptEditorWSServer::LLScriptEditorWSServer(const std::string& name, U16 port if (!handle_zoom_to_object(object_id)) { - LLSD response; - response["success"] = false; - response["error_code"] = WSCommandError::ExecutionError; - response["message"] = "Object not found or not reachable"; - return response; + throw LLJSONRPCConnection::InternalError( + "Object not found or not reachable"); } LLSD response; @@ -279,6 +290,18 @@ LLScriptEditorWSServer::ptr_t LLScriptEditorWSServer::ensureServerRunning() return server; } +std::string LLScriptEditorWSServer::buildScriptSubscriptionId(const LLUUID& object_id, + const LLUUID& item_id) +{ + std::string script_id = object_id.asString() + "_" + item_id.asString(); + + std::array script_id_hash_str = {}; + LLMD5 script_id_hash((const U8*)script_id.c_str()); + script_id_hash.hex_digest(script_id_hash_str.data()); + + return std::string(script_id_hash_str.data()); +} + std::string LLScriptEditorWSServer::buildVSCodeURI(const LLUUID& object_id, const LLUUID& script_id) { @@ -431,8 +454,12 @@ bool LLScriptEditorWSServer::subscribeScriptEditor(const LLUUID& object_id, cons if (it == mSubscriptions.end()) { // New subscription - mSubscriptions.emplace(script_id, - EditorSubscription(object_id, item_id, script_name, editor_handle)); + ItemRef item_ref; + item_ref.mPrimID = object_id; + item_ref.mItemID = item_id; + item_ref.mScriptName = script_name; + mSubscriptions.emplace(script_id, + EditorSubscription(item_ref, editor_handle)); } else { @@ -451,8 +478,6 @@ void LLScriptEditorWSServer::unsubscribeEditor(const std::string &script_id) auto connection = it->second.mConnection.lock(); mSubscriptions.erase(it); - // Maintain per-connection count; erase entry when it hits zero. - bool last_for_connection = false; if (connection_id != 0) { auto cit = mConnectionSubscriptionCounts.find(connection_id); @@ -461,21 +486,8 @@ void LLScriptEditorWSServer::unsubscribeEditor(const std::string &script_id) if (--cit->second <= 0) { mConnectionSubscriptionCounts.erase(cit); - last_for_connection = true; } } - else - { - // No counter entry means no other subs referenced this connection. - last_for_connection = true; - } - } - - if (connection && last_for_connection) - { // We have removed the last subscription, close the connection - LL_DEBUGS("ScriptEditorWS") << "Closing connection ID " << connection_id << - " as last subscription was removed" << LL_ENDL; - connection->sendDisconnect(LLScriptEditorWSConnection::DisconnectReason::EDITOR_CLOSED, "Editor closed"); } } @@ -597,7 +609,7 @@ void LLScriptEditorWSServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t c return s.handleSyntaxCacheFileRequest(params); })); - script_connection->registerMethod("script.subscribe", + script_connection->registerAsyncMethod("script.subscribe", bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params) { return s.handleScriptSubscribe(connection_id, params); @@ -609,7 +621,7 @@ void LLScriptEditorWSServer::setupConnectionMethods(LLJSONRPCConnection::ptr_t c return s.handleFileWatcherFileListRequest(); })); - script_connection->registerMethod("object.unpublish", + script_connection->registerAsyncMethod("object.unpublish", bindHandler([connection_id](LLScriptEditorWSServer& s, auto&, auto&, const LLSD& params) { return s.handleObjectUnpublish(connection_id, params); @@ -946,39 +958,27 @@ LLSD LLScriptEditorWSServer::handleSaveBackToObjectContents(U32 connection_id, c mPublishedObjectManager.getPublished(object_id); if (!published_info) { - LLSD response; - response["success"] = false; - response["error_code"] = WSCommandError::InvalidParams; - response["message"] = "Object is not published"; - return response; + throw LLJSONRPCConnection::InvalidParams( + "Object is not published"); } if (!published_info->mCanSaveBackToContents || published_info->mSourceTaskID.isNull()) { - LLSD response; - response["success"] = false; - response["error_code"] = WSCommandError::NotPermitted; - response["message"] = "Save back is not available for this object"; - return response; + throw LLJSONRPCConnection::ForbiddenError( + "Save back is not available for this object"); } LLViewerObject* root = gObjectList.findObject(object_id); if (!root) { - LLSD response; - response["success"] = false; - response["error_code"] = WSCommandError::InvalidParams; - response["message"] = "object_id not found"; - return response; + throw LLJSONRPCConnection::InvalidParams( + "object_id not found"); } if (!save_object_back_to_contents(root, published_info->mSourceTaskID)) { - LLSD response; - response["success"] = false; - response["error_code"] = WSCommandError::ExecutionError; - response["message"] = "Failed to save object back to contents"; - return response; + throw LLJSONRPCConnection::InternalError( + "Failed to save object back to contents"); } LL_DEBUGS("ScriptEditorWS") << "Save-back requested via command for object " @@ -1004,11 +1004,8 @@ LLSD LLScriptEditorWSServer::handleCommandExecute(U32 connection_id, const LLSD& auto it = mCommandRegistry.find(command); if (it == mCommandRegistry.end()) { - LLSD response; - response["success"] = false; - response["error_code"] = WSCommandError::UnknownCommand; - response["message"] = "Unknown command: " + command; - return response; + throw LLJSONRPCConnection::InvalidParams( + "Unknown command: " + command); } return it->second.second(connection_id, params["params"]); @@ -1088,27 +1085,32 @@ LLSD LLScriptEditorWSServer::handleSyntaxRequest(const LLSD& params) const if (category.empty()) { - response["error"] = "No syntax category specified"; - response["success"] = false; - return response; + throw LLJSONRPCConnection::InvalidParams( + "No syntax category specified"); } response["id"] = mLastSyntaxId; if (category == "defs.lua") { response["defs"] = LLSyntaxDefCache::instance().getLuaKeywords(); - response["success"] = response["defs"].isDefined(); } else if (category == "defs.lsl") { response["defs"] = LLSyntaxDefCache::instance().getLSLKeywords(); - response["success"] = response["defs"].isDefined(); } else { - response["error"] = "Unknown syntax category requested"; - response["success"] = false; + throw LLJSONRPCConnection::InvalidParams( + "Unknown syntax category requested"); + } + + if (!response["defs"].isDefined()) + { + throw LLJSONRPCConnection::InternalError( + "Syntax definitions are unavailable"); } + + response["success"] = true; return response; } @@ -1136,28 +1138,25 @@ LLSD LLScriptEditorWSServer::handleSyntaxCacheFileRequest(const LLSD& params) co if (filename.empty()) { - response["error"] = "No filename specified"; - response["success"] = false; - return response; + throw LLJSONRPCConnection::InvalidParams( + "No filename specified"); } if (!cache.hasCacheFile(filename)) { - response["error"] = "Requested syntax cache file not found"; - response["success"] = false; - return response; + throw LLJSONRPCConnection::InvalidParams( + "Requested syntax cache file not found"); } - bool success = false; if (as_json) { LLSD file_content = cache.loadCacheFileAsLLSD(filename); if (file_content.isDefined()) { response["content"] = file_content; - success = true; } else { - response["error"] = "Failed to load and format syntax cache file."; + throw LLJSONRPCConnection::InternalError( + "Failed to load and format syntax cache file."); } } else @@ -1166,14 +1165,14 @@ LLSD LLScriptEditorWSServer::handleSyntaxCacheFileRequest(const LLSD& params) co if (!content.empty()) { response["content"] = content; - success = true; } else { - response["error"] = "Failed to load syntax cache file"; + throw LLJSONRPCConnection::InternalError( + "Failed to load syntax cache file"); } } - response["success"] = success; + response["success"] = true; return response; } @@ -1217,10 +1216,22 @@ LLSD LLScriptEditorWSServer::handleScriptSubscribe(U32 connection_id, const LLSD auto it = mSubscriptions.find(script_id); if (it != mSubscriptions.end()) { - LLViewerObject* object = gObjectList.findObject((*it).second.mObjectID); - response["object_id"] = (*it).second.mObjectID; + LLUUID prim_id = (*it).second.mItemRef.mPrimID; + LLUUID root_id = prim_id; + LLViewerObject* object = gObjectList.findObject(prim_id); + if (object) + { + LLViewerObject* root = object->getRootEdit(); + if (root) + { + root_id = root->getID(); + } + } + + response["object_id"] = prim_id; + response["root_id"] = root_id; //response["object_name"] = object ? object->getName() : "Unknown"; - response["item_id"] = (*it).second.mItemID; + response["item_id"] = (*it).second.mItemRef.mItemID; } } @@ -1265,32 +1276,31 @@ LLSD LLScriptEditorWSServer::handleObjectRequest(U32 connection_id, const LLSD& if (object_id.isNull()) { - response["success"] = false; - response["message"] = "No object_id specified"; - return response; + throw LLJSONRPCConnection::InvalidParams( + "No object_id specified"); } LLViewerObject* object = gObjectList.findObject(object_id); if (!object) { - response["success"] = false; - response["message"] = "Object not found"; - return response; + throw LLJSONRPCConnection::InvalidParams( + "Object not found"); } if (!object->permModify()) { - response["success"] = false; - response["message"] = "Permission denied"; - return response; + throw LLJSONRPCConnection::ForbiddenError( + "Permission denied"); } bool accepted = publishObject(object_id); - response["success"] = accepted; if (!accepted) { - response["message"] = "Failed to initiate publish"; + throw LLJSONRPCConnection::InternalError( + "Failed to initiate publish"); } + + response["success"] = true; return response; } @@ -1531,7 +1541,16 @@ LLSD LLScriptEditorWSServer::saveScript(LLViewerObject* prim, LLInventoryItem* i response["compiled"] = cb_result["compiled"]; if (!cb_result["compiled"].asBoolean() && cb_result.has("errors")) { - response["errors"] = cb_result["errors"]; + response["diagnostics"] = LLSD::emptyArray(); + for (const auto& error : llsd::inArray(cb_result["errors"])) + { + LLSD diagnostic; + diagnostic["row"] = 0; + diagnostic["column"] = 0; + diagnostic["level"] = "ERROR"; + diagnostic["message"] = error.asString(); + response["diagnostics"].append(diagnostic); + } } // If the script is open in the viewer's editor, update it @@ -1934,12 +1953,10 @@ void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, co params["running"] = results["is_running"].asBoolean(); if (results.has("errors")) { - params["errors"] = LLSD::emptyArray(); + params["diagnostics"] = LLSD::emptyArray(); if (is_lua) { // lua errors: ":line: message", line is 1-based - const static boost::regex lua_err_regex(R"(^[^:]*:(\d+): (.+)$)"); - for (const auto& err : llsd::inArray(results["errors"])) { boost::smatch match; @@ -1948,10 +1965,10 @@ void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, co err_entry["column"] = 0; // TODO: Lua compiler does not provide column info err_entry["level"] = "ERROR"; - if (boost::regex_match(err.asString(), match, lua_err_regex)) + if (boost::regex_match(err.asString(), match, LUAU_LOCATION_PATTERN)) { - S32 line_number = std::stoi(match[1].str()); - std::string message = match[2].str(); + S32 line_number = std::stoi(match[2].str()); + std::string message = match[3].str(); err_entry["row"] = line_number; err_entry["message"] = message; @@ -1961,19 +1978,17 @@ void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, co err_entry["row"] = 0; err_entry["message"] = err.asString(); } - params["errors"].append(err_entry); + params["diagnostics"].append(err_entry); } } else { // lsl errors: "(line, column) : SEVERITY : message", line and column are 0-based - static const boost::regex lsl_err_regex(R"(\((\d+), (\d+)\) : ([^:]+) : (.+))"); - for (const auto& err : llsd::inArray(results["errors"])) { boost::smatch match; LLSD err_entry; - if (boost::regex_match(err.asString(), match, lsl_err_regex)) + if (boost::regex_match(err.asString(), match, LSL_LOCATION_PATTERN)) { S32 line_number = std::stoi(match[1].str()); S32 col_number = std::stoi(match[2].str()); @@ -1994,7 +2009,7 @@ void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, co err_entry["message"] = err.asString(); err_entry["format"] = "lsl"; } - params["errors"].append(err_entry); + params["diagnostics"].append(err_entry); } } } @@ -2002,138 +2017,73 @@ void LLScriptEditorWSServer::sendCompileResults(const std::string &script_id, co notifyScript(script_id, "script.compiled", params); } -void LLScriptEditorWSServer::forwardChatToIDE(const LLChat& chat_msg) const +void LLScriptEditorWSServer::forwardChatToIDE( + const LLChat& chat_msg, + LLPublishedObjectMgr::RuntimeEventAggregator::Channel channel) const { LL_PROFILE_ZONE_SCOPED_CATEGORY_SCRIPTDEV; - LLUUID object_id = chat_msg.mFromID; - bool tracking = false; - bool publish = false; + mPublishedObjectManager.ingestRuntimeChat(chat_msg, channel); +} - LLUUID publish_id = object_id; - LLViewerObject* objectp = gObjectList.findObject(object_id); - if (objectp) - { - LLViewerObject* root = objectp->getRootEdit(); - if (root) - { - publish_id = root->getID(); - } - } +void LLScriptEditorWSServer::sendRuntimeEvent( + const LLPublishedObjectMgr::RuntimeChatEvent& event) const +{ - const EditorSubscription* subinfo = nullptr; - std::string script_id; - // have we either published or subscribed to this object? - if (isObjectPublished(publish_id)) + std::string script_id; + if (event.mItemID.notNull()) { - tracking = true; - publish = true; - } - else - { - // If the object is not published, we may still be tracking it if it is a script we are subscribed to - auto it = std::find_if(mSubscriptions.begin(), mSubscriptions.end(), - [&object_id](const auto& pair) { return (pair.second.mObjectID == object_id); }); - if (it != mSubscriptions.end()) - { - tracking = true; - subinfo = &it->second; - script_id = it->first; - } + script_id = buildScriptSubscriptionId(event.mPrimID, event.mItemID); } - if (!tracking) - { // Not a script we are tracking - return; - } - - bool is_error = false; - std::string error_message; - std::string object_name = chat_msg.mFromName; - std::string script_name; - S32 line_number = 0; - // We have at least one script from this object, we will forward the message to the IDE - // but first we need to see if it is a runtime error - std::vector lines = LLStringUtil::getTokens(chat_msg.mText, "\n"); - // If this is a runtime error, the first line will look like: " [script: