Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions code/framework/src/integrations/client/instance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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());
}
}
Comment on lines +397 to +403

Copy link
Copy Markdown

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 ResourceStop arrives while a refresh download is pending, Line 591 later sees the resource as stopped and calls StartResource, 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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());
}
}
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);
if (!result) {
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Failed to stop client resource '{}': {}", res.name, result.GetError());
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/framework/src/integrations/client/instance.cpp` around lines 397 - 403,
The resource stop handling in the client instance path leaves matching entries
in the pending refresh queue, so a later refresh can call StartResource and
restart a server-stopped resource. Update the ResourceStop flow in instance.cpp
to remove any queued refresh/download entries for each resource in
payload.resources before calling rm->StopResource, and keep the logic aligned
with the later StartResource check so stopped resources are not resurrected.

}
});

// 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
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

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

Run completion cleanup before returning from hot reloads.

This early return skips the common _downloadStatus = {} reset and OnAssetsDownloadFinished(success) callback below, leaving mod-level code uninformed after hot-reload downloads.

🐛 Proposed fix
                 }
                 _pendingRefreshResources.clear();
+                Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->flush();
+                _downloadStatus = {};
+                OnAssetsDownloadFinished(success);
                 return;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_pendingRefreshResources.clear();
return;
_pendingRefreshResources.clear();
Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->flush();
_downloadStatus = {};
OnAssetsDownloadFinished(success);
return;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/framework/src/integrations/client/instance.cpp` around lines 597 - 598,
The early return in the hot-reload path of the instance cleanup flow skips the
shared completion steps after _pendingRefreshResources.clear(), so move or
duplicate the common cleanup in the same control path: ensure _downloadStatus is
reset and OnAssetsDownloadFinished(success) is invoked before returning from
this branch in the instance hot-reload handling. Use the surrounding logic in
the instance cleanup/download completion method to keep the hot-reload path
consistent with the normal completion path.

}
// 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());
Expand Down
6 changes: 6 additions & 0 deletions code/framework/src/integrations/client/instance.h
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ namespace Framework::Integrations::Client {
// Pending resources from server (stored here to survive scripting module reset)
std::vector<Client::Scripting::ServerResourceInfo> _pendingServerResources;

// Client resources the server hot-reloaded; refreshed after the next
// asset re-sync completes (dev mode). Empty on a normal connect.
std::vector<Client::Scripting::ServerResourceInfo> _pendingRefreshResources;

// Handshake state carried from ServerResources until the ReadyEvent spawn barrier completes.
int _readyEventId {};
float _serverTickRate {};
Expand All @@ -118,6 +122,8 @@ namespace Framework::Integrations::Client {
void InitNetworkingMessages();
void InitAssetDownloader();
void OnAssetsDownloaded(bool success);
// Targeted delta re-sync for a hot-reload (does not stop all resources).
void SyncResourceUpdatesFromServer();
void InitCacheAssetFolders();
void RegisterScriptingBuiltins(Framework::Scripting::Engine *);

Expand Down
185 changes: 183 additions & 2 deletions code/framework/src/integrations/server/instance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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");
}
Expand All @@ -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);
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 BroadcastRPC(...) directly. As per coding guidelines, code/**/*.{cpp,hpp,h}: Use FW_SEND_COMPONENT_RPC(rpc, ...) and FW_SEND_COMPONENT_RPC_TO(rpc, guid, ...) macros for network communication in the RPC system.

Also applies to: 509-511

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/framework/src/integrations/server/instance.cpp` around lines 479 - 481,
The RPC sends in the resource refresh paths bypass the framework abstraction by
calling net->BroadcastRPC directly. Update the relevant send sites in Instance
to route the ResourceRefresh RPC through the framework macros instead, using
FW_SEND_COMPONENT_RPC or FW_SEND_COMPONENT_RPC_TO as appropriate, and keep the
RPC construction logic intact while replacing the direct network call.

Source: 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.

BroadcastResourceStop() only notifies current clients. New clients still get the stopped resource afterward, because the initial sync is built from GetClientResourceList() and InitAssetStreamer(), and both helpers shown in this PR still enumerate discovered client resources instead of the running set. After stop <resource>, reconnecting clients can re-download/restart a resource the server has already stopped. Please make the initial resource list and streamer contents derive from running resources, and rebuild that state when a client resource stops.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/framework/src/integrations/server/instance.cpp` around lines 486 - 514,
Stopped client resources are still being included in the initial sync because
the server-side resource state is only broadcast to existing clients and the
startup sync helpers still use the discovered client list. Update the resource
sync flow so GetClientResourceList and InitAssetStreamer build from the
currently running resources instead of all discovered client resources, and make
Instance::BroadcastResourceStop trigger a refresh/rebuild of that
running-resource state whenever a client resource is stopped.


void Instance::InitCommandListener() {
Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Setting up command listener and processor...");

Expand All @@ -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 &) {
Expand All @@ -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");
}

Expand Down
Loading
Loading