-
Notifications
You must be signed in to change notification settings - Fork 18
Scripting: resource hot-reload (dev mode) #220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
ec1d789
2967fb8
1378503
569e916
ee21756
289c7a7
eb1b902
e2ae086
b028579
e0aadd4
d1c7854
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,6 +13,7 @@ | |||||||||||||||
| #include "networking/rpc/rpc.h" | ||||||||||||||||
| #include "networking/rpc/chat_message.h" | ||||||||||||||||
| #include "networking/rpc/client_identity.h" | ||||||||||||||||
| #include "networking/rpc/resource_refresh.h" | ||||||||||||||||
| #include "networking/rpc/server_resources.h" | ||||||||||||||||
|
|
||||||||||||||||
| #include "scripting/resource/resource_manager.h" | ||||||||||||||||
|
|
@@ -350,6 +351,59 @@ namespace Framework::Integrations::Client { | |||||||||||||||
| DownloadsAssetsFromConnectedServer(); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| // Server hot-reloaded a client resource (dev mode): re-sync its files | ||||||||||||||||
| // (delta) and restart just that resource, leaving the rest running. | ||||||||||||||||
| net->RegisterRPC<Framework::Networking::RPC::ResourceRefresh>([this](const Framework::Networking::RPC::ResourceRefresh &payload, MafiaNet::Packet *) { | ||||||||||||||||
| if (payload.resources.empty()) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| // Ignore until connected with a running module, else we'd cancel | ||||||||||||||||
| // the initial download (which already fetches current files). | ||||||||||||||||
| auto *sm = GetScriptingModule(); | ||||||||||||||||
| if (!sm || !sm->GetScriptingEngine() || !sm->GetScriptingEngine()->IsInitialized()) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| // Accumulate (deduped): one reload arrives as several RPCs and a | ||||||||||||||||
| // single delta download covers them all; overwriting would drop all but the last. | ||||||||||||||||
| for (const auto &r : payload.resources) { | ||||||||||||||||
| bool known = false; | ||||||||||||||||
| for (const auto &e : _pendingRefreshResources) { | ||||||||||||||||
| if (e.name == r.name) { known = true; break; } | ||||||||||||||||
| } | ||||||||||||||||
| if (!known) { | ||||||||||||||||
| _pendingRefreshResources.push_back(r); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("Server hot-reloaded {} resource(s); re-syncing", payload.resources.size()); | ||||||||||||||||
| // An in-flight re-sync already covers these; it drains the full set on complete. | ||||||||||||||||
| if (!_downloadStatus.downloading) { | ||||||||||||||||
| SyncResourceUpdatesFromServer(); | ||||||||||||||||
| } | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| // Server stopped a client resource at runtime: stop it here too. | ||||||||||||||||
| net->RegisterRPC<Framework::Networking::RPC::ResourceStop>([this](const Framework::Networking::RPC::ResourceStop &payload, MafiaNet::Packet *) { | ||||||||||||||||
| if (payload.resources.empty()) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| auto *sm = GetScriptingModule(); | ||||||||||||||||
| if (!sm || !sm->GetScriptingEngine() || !sm->GetScriptingEngine()->IsInitialized()) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| auto *rm = sm->GetResourceManager(); | ||||||||||||||||
| if (!rm) { | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| for (const auto &res : payload.resources) { | ||||||||||||||||
| if (rm->IsResourceRunning(res.name)) { | ||||||||||||||||
| auto result = rm->StopResource(res.name); | ||||||||||||||||
| if (!result) { | ||||||||||||||||
| Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Failed to stop client resource '{}': {}", res.name, result.GetError()); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| // Spawn barrier complete: activate replication and report the connection final. | ||||||||||||||||
| net->SetOnConnectionReadyCallback([this, net](int eventId) { | ||||||||||||||||
| // Only the event the server assigned in ServerResources finalizes this connection, and | ||||||||||||||||
|
|
@@ -484,12 +538,68 @@ namespace Framework::Integrations::Client { | |||||||||||||||
| _downloadStatus.setID = streamer->DownloadFromSubdirectory(nullptr, nullptr, true, net->GetPeer()->GetSystemAddressFromIndex(0), new AssetDownloadFileProgress(this), MafiaNet::Priority::High, 2, nullptr); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| void Instance::SyncResourceUpdatesFromServer() { | ||||||||||||||||
| const auto net = GetNetworkingEngine()->GetNetworkClient(); | ||||||||||||||||
| if (net->GetConnectionState() != Framework::Networking::PeerState::CONNECTED) { | ||||||||||||||||
| Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Cannot re-sync resources while not connected"); | ||||||||||||||||
| _pendingRefreshResources.clear(); | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Unlike DownloadsAssetsFromConnectedServer, does NOT stop all resources | ||||||||||||||||
| // or tear down web views; cache path is already set from connect. | ||||||||||||||||
| const auto streamer = net->GetAssetStreamer(); | ||||||||||||||||
| const auto cacheDir = GetAssetCachePath(); | ||||||||||||||||
| if (cacheDir.empty()) { | ||||||||||||||||
| Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("No asset cache path set; cannot re-sync resources"); | ||||||||||||||||
| _pendingRefreshResources.clear(); | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
| streamer->SetApplicationDirectory(cacheDir.c_str()); | ||||||||||||||||
|
|
||||||||||||||||
| if (_downloadStatus.downloading) { | ||||||||||||||||
| net->GetFileListTransfer()->CancelReceive(_downloadStatus.setID); | ||||||||||||||||
| _downloadStatus = {}; | ||||||||||||||||
| } | ||||||||||||||||
| _downloadStatus.downloading = true; | ||||||||||||||||
| _downloadStatus.setID = streamer->DownloadFromSubdirectory(nullptr, nullptr, true, net->GetPeer()->GetSystemAddressFromIndex(0), new AssetDownloadFileProgress(this), MafiaNet::Priority::High, 2, nullptr); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| void Instance::OnAssetsDownloaded(bool success) { | ||||||||||||||||
| const auto net = GetNetworkingEngine()->GetNetworkClient(); | ||||||||||||||||
| if (success) { | ||||||||||||||||
| Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("All the assets have been downloaded!"); | ||||||||||||||||
|
|
||||||||||||||||
| auto scriptingModule = GetScriptingModule(); | ||||||||||||||||
|
|
||||||||||||||||
| // Hot-reload path: module already running, refresh just the flagged | ||||||||||||||||
| // resources instead of re-initializing everything. | ||||||||||||||||
| if (scriptingModule && scriptingModule->GetScriptingEngine() | ||||||||||||||||
| && scriptingModule->GetScriptingEngine()->IsInitialized() | ||||||||||||||||
| && !_pendingRefreshResources.empty()) { | ||||||||||||||||
| if (auto *rm = scriptingModule->GetResourceManager()) { | ||||||||||||||||
| for (const auto &res : _pendingRefreshResources) { | ||||||||||||||||
| // Newly started server-side: discover from cache first. | ||||||||||||||||
| if (!rm->HasResource(res.name)) { | ||||||||||||||||
| const std::string resPath = GetAssetCachePath() + "/" + res.name; | ||||||||||||||||
| if (!rm->DiscoverResource(resPath)) { | ||||||||||||||||
| Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Could not discover new client resource '{}' at {}", res.name, resPath); | ||||||||||||||||
| continue; | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| // Reload if running, start if newly discovered/stopped. | ||||||||||||||||
| auto result = rm->IsResourceRunning(res.name) ? rm->RefreshResource(res.name) : rm->StartResource(res.name); | ||||||||||||||||
| if (!result) { | ||||||||||||||||
| Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Failed to sync client resource '{}': {}", res.name, result.GetError()); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| _pendingRefreshResources.clear(); | ||||||||||||||||
| return; | ||||||||||||||||
|
Comment on lines
+597
to
+598
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Run completion cleanup before returning from hot reloads. This early return skips the common 🐛 Proposed fix }
_pendingRefreshResources.clear();
+ Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->flush();
+ _downloadStatus = {};
+ OnAssetsDownloadFinished(success);
return;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| } | ||||||||||||||||
| // A refresh that raced an initial connect falls through to full init. | ||||||||||||||||
| _pendingRefreshResources.clear(); | ||||||||||||||||
|
|
||||||||||||||||
| if (scriptingModule) { | ||||||||||||||||
| // Set resource cache path before init | ||||||||||||||||
| scriptingModule->SetResourceCachePath(GetAssetCachePath()); | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ | |
| #include "networking/replication/replication_manager.h" | ||
| #include "networking/rpc/chat_message.h" | ||
| #include "networking/rpc/client_identity.h" | ||
| #include "networking/rpc/resource_refresh.h" | ||
| #include "networking/rpc/server_resources.h" | ||
|
|
||
| #include "networking/connection.h" | ||
|
|
@@ -152,6 +153,7 @@ namespace Framework::Integrations::Server { | |
|
|
||
| // Initialize the scripting engine | ||
| _scriptingModule->SetResourcesPath(_opts.resourcesPath); | ||
| _scriptingModule->SetDevMode(_opts.developmentMode); | ||
| if (_scriptingModule->Init(sdkCallback) != Framework::Scripting::ScriptingError::SCRIPTING_NONE) { | ||
| return Error("Failed to initialize the scripting engine"); | ||
| } | ||
|
|
@@ -177,11 +179,25 @@ namespace Framework::Integrations::Server { | |
| // Initialize asset streamer (needs discovered resources to know client files) | ||
| InitAssetStreamer(); | ||
|
|
||
| // Mirror runtime resource start/stop to clients. Gated on boot (the | ||
| // StartAll below predates any connection) and shutdown. | ||
| _scriptingModule->GetResourceManager()->SetOnResourceStarted([this](const std::string &name) { | ||
| if (_resourcesBooted && !_shuttingDown) { | ||
| BroadcastResourceRefresh(name); | ||
| } | ||
| }); | ||
| _scriptingModule->GetResourceManager()->SetOnResourceStopped([this](const std::string &name) { | ||
| if (_resourcesBooted && !_shuttingDown) { | ||
| BroadcastResourceStop(name); | ||
| } | ||
| }); | ||
|
|
||
| // Start all resources (ES modules load asynchronously via normal Update cycle) | ||
| auto startResult = _scriptingModule->GetResourceManager()->StartAll(); | ||
| if (!startResult) { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to start resources: {}", startResult.GetError()); | ||
| } | ||
| _resourcesBooted = true; | ||
|
|
||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->flush(); | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Host:\t{}", _opts.bindHost); | ||
|
|
@@ -431,6 +447,72 @@ namespace Framework::Integrations::Server { | |
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Asset streamer ready with {} client files", streamer->GetNumberOfFilesForUpload()); | ||
| } | ||
|
|
||
| void Instance::BroadcastResourceRefresh(const std::string &name) { | ||
| if (!_scriptingModule || !_networkingEngine) { | ||
| return; | ||
| } | ||
| auto *rm = _scriptingModule->GetResourceManager(); | ||
| if (!rm) { | ||
| return; | ||
| } | ||
| const auto *resource = rm->GetResource(name); | ||
| if (!resource) { | ||
| return; | ||
| } | ||
| // Only resources with a client entry point need a client-side refresh. | ||
| if (resource->GetManifest().GetMafiaHubConfig().client.empty()) { | ||
| return; | ||
| } | ||
|
|
||
| const auto net = _networkingEngine->GetNetworkServer(); | ||
| if (!net) { | ||
| return; | ||
| } | ||
|
|
||
| // Rebuild the streamer's upload list so changed files get fresh hashes; | ||
| // the delta transfer compares stored hashes. See docs/resource_hot_reload.md. | ||
| if (auto *streamer = net->GetAssetStreamer()) { | ||
| streamer->ClearUploads(); | ||
| } | ||
| InitAssetStreamer(); | ||
|
|
||
| Framework::Networking::RPC::ResourceRefresh refresh; | ||
| refresh.resources.push_back({resource->GetName(), resource->GetVersion()}); | ||
| net->BroadcastRPC(refresh); | ||
|
Comment on lines
+479
to
+481
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Use the framework RPC send macros here. These new RPC sends bypass the required framework abstraction. Please route them through the RPC macros instead of calling Also applies to: 509-511 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Broadcasting hot-reload of resource '{}' to clients", name); | ||
| } | ||
|
|
||
| void Instance::BroadcastResourceStop(const std::string &name) { | ||
| if (!_scriptingModule || !_networkingEngine) { | ||
| return; | ||
| } | ||
| auto *rm = _scriptingModule->GetResourceManager(); | ||
| if (!rm) { | ||
| return; | ||
| } | ||
| const auto *resource = rm->GetResource(name); | ||
| if (!resource) { | ||
| return; | ||
| } | ||
| // Only client resources need a client-side stop. | ||
| if (resource->GetManifest().GetMafiaHubConfig().client.empty()) { | ||
| return; | ||
| } | ||
|
|
||
| const auto net = _networkingEngine->GetNetworkServer(); | ||
| if (!net) { | ||
| return; | ||
| } | ||
|
|
||
| // No streamer rebuild: stopping ships no files. | ||
| Framework::Networking::RPC::ResourceStop stop; | ||
| stop.resources.push_back({resource->GetName(), resource->GetVersion()}); | ||
| net->BroadcastRPC(stop); | ||
|
|
||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Broadcasting stop of resource '{}' to clients", name); | ||
| } | ||
|
Comment on lines
+486
to
+514
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Stopped resources still remain in the initial sync path.
🤖 Prompt for AI Agents |
||
|
|
||
| void Instance::InitCommandListener() { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Setting up command listener and processor..."); | ||
|
|
||
|
|
@@ -450,13 +532,36 @@ namespace Framework::Integrations::Server { | |
| "Show this help message"); | ||
|
|
||
| _commandProcessor->RegisterCommand( | ||
| "stop", {}, | ||
| "quit", {}, | ||
| [this](cxxopts::ParseResult &) { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Stopping server..."); | ||
| Shutdown(); | ||
| }, | ||
| "Stop the server"); | ||
|
|
||
| // No argument: stop the server (back-compat). With one: stop a resource. | ||
| _commandProcessor->RegisterCommand( | ||
| "stop", {}, | ||
| [this](cxxopts::ParseResult &result) { | ||
| const auto &args = result.unmatched(); | ||
| if (args.empty()) { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Stopping server..."); | ||
| Shutdown(); | ||
| return; | ||
| } | ||
| auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; | ||
| if (!rm) { | ||
| return; | ||
| } | ||
| auto res = rm->StopResource(args[0]); | ||
| if (res) { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Stopped resource '{}'", args[0]); | ||
| } else { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to stop '{}': {}", args[0], res.GetError()); | ||
| } | ||
| }, | ||
| "Stop a resource (stop <resource>), or the server if no resource is given"); | ||
|
|
||
| _commandProcessor->RegisterCommand( | ||
| "status", {}, | ||
| [this](cxxopts::ParseResult &) { | ||
|
|
@@ -471,7 +576,83 @@ namespace Framework::Integrations::Server { | |
| } | ||
| }, | ||
| "Show server status"); | ||
|
|
||
|
|
||
| // Resource lifecycle commands. Helper folds the shared boilerplate | ||
| // (resolve manager, require a name, log the result). | ||
| auto resourceCommand = [this](std::string_view verb, auto op) { | ||
| return [this, verb, op](cxxopts::ParseResult &result) { | ||
| auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; | ||
| if (!rm) { | ||
| return; | ||
| } | ||
| const auto &args = result.unmatched(); | ||
| if (args.empty()) { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("Usage: {} <resource>", verb); | ||
| return; | ||
| } | ||
| auto res = op(rm, args[0]); | ||
| if (res) { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("{}: '{}'", verb, args[0]); | ||
| } else { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to {} '{}': {}", verb, args[0], res.GetError()); | ||
| } | ||
| }; | ||
| }; | ||
|
|
||
| _commandProcessor->RegisterCommand( | ||
| "start", {}, | ||
| resourceCommand("start", [](Framework::Scripting::ResourceManager *rm, const std::string &n) { | ||
| return rm->StartResource(n); | ||
| }), | ||
| "Start a resource: start <resource>"); | ||
|
|
||
| _commandProcessor->RegisterCommand( | ||
| "restart", {}, | ||
| resourceCommand("restart", [](Framework::Scripting::ResourceManager *rm, const std::string &n) { | ||
| if (!rm->IsResourceRunning(n)) { | ||
| return Framework::Scripting::ResourceOperationResult(std::string("resource is not running (use start)")); | ||
| } | ||
| return rm->RestartResource(n); | ||
| }), | ||
| "Reload a running resource's code: restart <resource>"); | ||
|
|
||
| // Start-or-reload — the canonical verb FiveM/MTASA operators expect. | ||
| _commandProcessor->RegisterCommand( | ||
| "ensure", {}, | ||
| resourceCommand("ensure", [](Framework::Scripting::ResourceManager *rm, const std::string &n) { | ||
| return rm->IsResourceRunning(n) ? rm->RefreshResource(n) : rm->StartResource(n); | ||
| }), | ||
| "Start or reload a resource: ensure <resource>"); | ||
|
|
||
| // Re-scan for new/changed resources (manifests), without restarting. | ||
| _commandProcessor->RegisterCommand( | ||
| "refresh", {}, | ||
| [this](cxxopts::ParseResult &) { | ||
| auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; | ||
| if (!rm) { | ||
| return; | ||
| } | ||
| auto added = rm->Rescan(); | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed resources ({} new)", added.size()); | ||
| }, | ||
| "Re-scan the resources directory for new/changed resources"); | ||
|
|
||
| _commandProcessor->RegisterCommand( | ||
| "refreshall", {}, | ||
| [this](cxxopts::ParseResult &) { | ||
| auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; | ||
| if (!rm) { | ||
| return; | ||
| } | ||
| auto res = rm->RefreshAll(); | ||
| if (res) { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed all resources ({} affected)", res.GetValue().size()); | ||
| } else { | ||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to refresh all resources: {}", res.GetError()); | ||
| } | ||
| }, | ||
| "Re-scan and reload all running resources from disk"); | ||
|
|
||
| Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Command listener and processor initialized"); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Remove stopped resources from the pending refresh queue.
If
ResourceStoparrives while a refresh download is pending, Line 591 later sees the resource as stopped and callsStartResource, resurrecting a server-stopped resource. Drop matching pending refresh entries before stopping.🐛 Proposed fix
for (const auto &res : payload.resources) { + for (auto it = _pendingRefreshResources.begin(); it != _pendingRefreshResources.end();) { + if (it->name == res.name) { + it = _pendingRefreshResources.erase(it); + } + else { + ++it; + } + } if (rm->IsResourceRunning(res.name)) { auto result = rm->StopResource(res.name);📝 Committable suggestion
🤖 Prompt for AI Agents