From ec1d789b429de2ff91c06a2eaf45b16a242f7292 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:36:21 +0200 Subject: [PATCH 01/11] Scripting: evict resource modules on hot-reload ReloadResource previously did Stop+Start without clearing any module cache, so re-executing the entry point returned stale require() exports and edits to non-entry files were silently ignored. Add Engine::EvictModulesUnderPath(rootPath): - V8Engine erases _moduleCache and _requireDataStore entries inside the resource directory (scoped, unlike global ClearModuleCache). - NodeEngine installs a privileged __fw_evictModulesUnderPath hook in the bootstrap that captures publicRequire.cache before sandboxing (which hides require.cache), and deletes CJS cache keys under the root. - ReloadResource captures the resource root, stops, evicts, then starts. --- code/framework/src/scripting/engine.h | 10 ++++ code/framework/src/scripting/node_engine.cpp | 46 ++++++++++++++ code/framework/src/scripting/node_engine.h | 7 +++ .../scripting/resource/resource_manager.cpp | 24 +++++++- code/framework/src/scripting/v8_engine.cpp | 60 +++++++++++++++++++ code/framework/src/scripting/v8_engine.h | 7 +++ 6 files changed, 153 insertions(+), 1 deletion(-) diff --git a/code/framework/src/scripting/engine.h b/code/framework/src/scripting/engine.h index 8446d33a9..5b53facaa 100644 --- a/code/framework/src/scripting/engine.h +++ b/code/framework/src/scripting/engine.h @@ -64,6 +64,16 @@ namespace Framework::Scripting { */ virtual bool ExecuteFile(std::string_view filepath) = 0; + /** + * Evict cached modules whose resolved path sits inside the given root + * directory. Used by hot-reload so re-executing a resource's entry + * point re-reads edited files from disk instead of returning stale + * cached exports. Must be called only after the owning resource has + * been stopped. Default is a no-op for engines without a module cache. + * @param rootPath Path to a resource directory + */ + virtual void EvictModulesUnderPath(const std::string &rootPath) {} + /** * Register framework SDK bindings. * Called after Init() to set up Framework.* APIs. diff --git a/code/framework/src/scripting/node_engine.cpp b/code/framework/src/scripting/node_engine.cpp index 516e5e977..4975efd89 100644 --- a/code/framework/src/scripting/node_engine.cpp +++ b/code/framework/src/scripting/node_engine.cpp @@ -205,6 +205,23 @@ namespace Framework::Scripting { "globalThis.require = publicRequire;" "globalThis.Framework = {};" "globalThis.Core = {};" + // Privileged hot-reload hook: capture the real CommonJS module cache + // now, before require() is sandboxed (which hides require.cache). + // Lets C++ evict a resource's modules on reload. + "Object.defineProperty(globalThis, '__fw_evictModulesUnderPath', {" + " value: function(root, ci) {" + " try {" + " const cache = publicRequire.cache; if (!cache) return 0;" + " let r = String(root).replace(/\\\\/g, '/'); if (ci) r = r.toLowerCase();" + " let removed = 0;" + " for (const k of Object.keys(cache)) {" + " let nk = k.replace(/\\\\/g, '/'); if (ci) nk = nk.toLowerCase();" + " if (nk.indexOf(r) === 0) { delete cache[k]; removed++; }" + " }" + " return removed;" + " } catch (e) { return 0; }" + " }, writable: false, configurable: false, enumerable: false" + "});" "process.setUncaughtExceptionCaptureCallback((err) => {" " try {" " const msg = err instanceof Error ? (err.stack || err.message) : String(err);" @@ -320,6 +337,35 @@ namespace Framework::Scripting { return Execute(code, absPathStr); } + void NodeEngine::EvictModulesUnderPath(const std::string &rootPath) { + if (!_initialized) { + return; + } + + std::error_code ec; + std::filesystem::path absRoot = std::filesystem::weakly_canonical(rootPath, ec); + std::string rootStr = (ec ? std::filesystem::path(rootPath) : absRoot).generic_string(); + if (rootStr.empty()) { + return; + } + // Trailing slash enforces a directory boundary in the JS startsWith + // check so /res/a doesn't evict modules of /res/ab. + if (rootStr.back() != '/') { + rootStr += '/'; + } + +#ifdef _WIN32 + const char *caseInsensitive = "true"; +#else + const char *caseInsensitive = "false"; +#endif + std::string escaped = EscapeForSingleQuotedJSString(rootStr); + std::string code = + "if (typeof globalThis.__fw_evictModulesUnderPath === 'function')" + " globalThis.__fw_evictModulesUnderPath('" + escaped + "', " + caseInsensitive + ");"; + Execute(code, ""); + } + v8::Local NodeEngine::GetContext() const { if (_setup) { return _setup->context(); diff --git a/code/framework/src/scripting/node_engine.h b/code/framework/src/scripting/node_engine.h index c56a064f8..6ce46ae00 100644 --- a/code/framework/src/scripting/node_engine.h +++ b/code/framework/src/scripting/node_engine.h @@ -86,6 +86,13 @@ namespace Framework::Scripting { void Shutdown() override; bool ExecuteFile(std::string_view filepath) override; + /** + * Evict CommonJS modules cached under rootPath so a subsequent + * require() of the resource entry point re-reads edited files. Used by + * hot-reload; call only after the owning resource has been stopped. + */ + void EvictModulesUnderPath(const std::string &rootPath) override; + /** * Process pending Node.js events (non-blocking). * Call this from game loop to process async operations. diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index a3a57ee28..b63d61122 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -387,7 +387,29 @@ namespace Framework::Scripting { } ResourceOperationResult ResourceManager::ReloadResource(std::string_view name) { - return RestartResource(name); + // Capture the resource root before stopping so we can evict its cached + // modules. Without eviction, re-executing the entry point returns stale + // require() exports and on-disk edits to non-entry files are ignored. + std::string resourceRoot; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it != _resources.end() && it->second) { + resourceRoot = it->second->GetPath(); + } + } + + auto stopResult = StopResource(name); + if (!stopResult) { + return stopResult; + } + + // Evict only after the resource is stopped (engine cache contract). + if (_jsEngine && !resourceRoot.empty()) { + _jsEngine->EvictModulesUnderPath(resourceRoot); + } + + return StartResource(name); } bool ResourceManager::ExecuteResourceScript(Resource &resource, std::string &outError) { diff --git a/code/framework/src/scripting/v8_engine.cpp b/code/framework/src/scripting/v8_engine.cpp index 35b4f4e4e..219761be5 100644 --- a/code/framework/src/scripting/v8_engine.cpp +++ b/code/framework/src/scripting/v8_engine.cpp @@ -65,6 +65,66 @@ namespace Framework::Scripting { _requireDataStore.clear(); } + void V8Engine::EvictModulesUnderPath(const std::string &rootPath) { + if (!_isolate) { + return; + } + + // Disposing v8::Global handles touches the isolate; since the engine + // runs in locking mode (Engine::Execute takes a Locker), hold one here + // too. No HandleScope: Global::Reset needs none, and we create no + // Local handles. Locker is reentrant, so a locked caller is fine. + v8::Locker locker(_isolate); + v8::Isolate::Scope isolateScope(_isolate); + + namespace fs = std::filesystem; + std::error_code ec; + fs::path canonicalRoot = fs::weakly_canonical(fs::path(rootPath), ec); + std::string rootStr = (ec ? fs::path(rootPath) : canonicalRoot).string(); + if (rootStr.empty()) { + return; + } + + auto insideRoot = [&](const std::string &path) { + std::string p = path; + std::string r = rootStr; +#ifdef _WIN32 + // Windows filesystems are case-insensitive; normalize both sides. + std::transform(p.begin(), p.end(), p.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + std::transform(r.begin(), r.end(), r.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); +#endif + if (p.size() < r.size() || p.compare(0, r.size(), r) != 0) { + return false; + } + // Require a directory boundary so /res/a doesn't match /res/ab. + if (p.size() > r.size()) { + char next = p[r.size()]; + char sep = static_cast(fs::path::preferred_separator); + if (next != sep && next != '/' && r.back() != sep) { + return false; + } + } + return true; + }; + + for (auto it = _moduleCache.begin(); it != _moduleCache.end();) { + if (insideRoot(it->first)) { + it = _moduleCache.erase(it); + } else { + ++it; + } + } + + _requireDataStore.erase( + std::remove_if(_requireDataStore.begin(), _requireDataStore.end(), + [&](const std::unique_ptr &d) { + return d && insideRoot(d->currentDir); + }), + _requireDataStore.end()); + } + void V8Engine::Shutdown() { if (!_initialized) { return; diff --git a/code/framework/src/scripting/v8_engine.h b/code/framework/src/scripting/v8_engine.h index 88e450dda..e3ec70a1b 100644 --- a/code/framework/src/scripting/v8_engine.h +++ b/code/framework/src/scripting/v8_engine.h @@ -84,6 +84,13 @@ namespace Framework::Scripting { */ void ClearModuleCache(); + /** + * Evict cached modules (and their require() backing data) whose + * resolved path sits inside rootPath. Used by hot-reload; call only + * after the owning resource has been stopped. + */ + void EvictModulesUnderPath(const std::string &rootPath) override; + // Module loader internals (used by require callback) v8::MaybeLocal LoadModule(std::string_view requestedPath, std::string_view referencingDir); From 2967fb82abe252de252bcf0270b2977602b583d1 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:43:15 +0200 Subject: [PATCH 02/11] Scripting: add refresh / refreshall console commands Manual hot-reload triggers (MTASA-style) on top of module eviction. - ResourceManager::RefreshResource re-parses package.json from disk, evicts cached modules, and restarts the resource (and any dependents that cascaded down) if it was running; stopped resources stay stopped. - ResourceManager::RefreshAll stops running resources, re-parses every known resource, picks up newly added directories (RescanResources), then restarts exactly those that were running. - Server Instance registers 'refresh ' and 'refreshall' console commands wired to the above. --- .../src/integrations/server/instance.cpp | 39 ++++- .../scripting/resource/resource_manager.cpp | 155 ++++++++++++++++++ .../src/scripting/resource/resource_manager.h | 22 +++ 3 files changed, 215 insertions(+), 1 deletion(-) diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 0fd0309db..9414026ff 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -471,7 +471,44 @@ namespace Framework::Integrations::Server { } }, "Show server status"); - + + _commandProcessor->RegisterCommand( + "refresh", {}, + [this](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: refresh "); + return; + } + auto res = rm->RefreshResource(args[0]); + if (res) { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed resource '{}'", args[0]); + } else { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to refresh '{}': {}", args[0], res.GetError()); + } + }, + "Reload a resource from disk: refresh "); + + _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 resources from disk"); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Command listener and processor initialized"); } diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index b63d61122..32ebc1a1e 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -412,6 +412,161 @@ namespace Framework::Scripting { return StartResource(name); } + ResourceOperationResult ResourceManager::RefreshResource(std::string_view name) { + std::string path; + bool wasRunning = false; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it == _resources.end() || !it->second) { + return ResourceOperationResult("Resource not found: " + std::string(name)); + } + path = it->second->GetPath(); + wasRunning = it->second->IsRunning(); + } + + // Stop first (cascades to dependents) so we can replace the registry + // entry and evict its modules safely. Remember everything that went + // down so we can bring it all back up afterwards. + std::vector toRestart; + if (wasRunning) { + auto stopResult = StopResource(name); + if (!stopResult) { + return stopResult; + } + toRestart = stopResult.GetValue(); + } + + if (_jsEngine && !path.empty()) { + _jsEngine->EvictModulesUnderPath(path); + } + + // Re-parse package.json so manifest edits (deps, entry points) apply. + auto reparsed = std::make_unique(path); + if (!reparsed->IsManifestValid()) { + return ResourceOperationResult("Invalid package.json in " + path + ": " + reparsed->GetErrorMessage()); + } + const std::string oldName = std::string(name); + std::string newName = reparsed->GetName(); + { + std::scoped_lock lock(_resourcesMutex); + if (newName != oldName) { + _resources.erase(oldName); + } + _resources[newName] = std::move(reparsed); + } + BuildDependencyGraph(); + + if (!wasRunning) { + return ResourceOperationResult::Ok({newName}); + } + + // Restart the resource itself plus any dependents that cascaded down. + // StartResource pulls dependencies in automatically, so order is safe. + std::vector affected; + for (const auto &stoppedName : toRestart) { + const std::string startName = (stoppedName == oldName) ? newName : stoppedName; + auto result = StartResource(startName); + if (result) { + const auto &a = result.GetValue(); + affected.insert(affected.end(), a.begin(), a.end()); + } + } + return ResourceOperationResult::Ok(affected); + } + + ResourceOperationResult ResourceManager::RefreshAll() { + // Remember who was running so we restart exactly them. + auto running = GetRunningResourceNames(); + + // Stop everything currently running (reverse dependency order). + StopAll(); + + // Evict + re-parse every known resource so code/manifest edits apply. + for (const auto &name : GetAllResourceNames()) { + std::string path; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it != _resources.end() && it->second) { + path = it->second->GetPath(); + } + } + if (path.empty()) { + continue; + } + if (_jsEngine) { + _jsEngine->EvictModulesUnderPath(path); + } + auto reparsed = std::make_unique(path); + if (!reparsed->IsManifestValid()) { + Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) + ->warn("Skipping invalid package.json in {}: {}", path, reparsed->GetErrorMessage()); + continue; + } + std::string newName = reparsed->GetName(); + std::scoped_lock lock(_resourcesMutex); + if (newName != name) { + _resources.erase(name); + } + _resources[newName] = std::move(reparsed); + } + + // Pick up brand-new resource directories, then rebuild the graph once. + RescanResources(); + BuildDependencyGraph(); + + // Restart exactly what was running before. + std::vector affected; + for (const auto &name : running) { + auto result = StartResource(name); + if (result) { + const auto &a = result.GetValue(); + affected.insert(affected.end(), a.begin(), a.end()); + } + } + return ResourceOperationResult::Ok(affected); + } + + std::vector ResourceManager::RescanResources() { + std::vector added; + + std::error_code ec; + std::filesystem::path resourcesDir(_config.resourcesPath); + if (!std::filesystem::exists(resourcesDir, ec)) { + return added; + } + + for (const auto &entry : std::filesystem::directory_iterator(resourcesDir, ec)) { + if (ec) { + break; + } + if (!entry.is_directory()) { + continue; + } + if (!std::filesystem::exists(entry.path() / "package.json")) { + continue; + } + + Resource probe(entry.path().string()); + if (!probe.IsManifestValid()) { + continue; + } + + bool exists; + { + std::scoped_lock lock(_resourcesMutex); + exists = _resources.contains(probe.GetName()); + } + if (!exists && DiscoverResource(entry.path().string())) { + added.push_back(probe.GetName()); + Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) + ->info("Discovered new JS resource: {}", added.back()); + } + } + return added; + } + bool ResourceManager::ExecuteResourceScript(Resource &resource, std::string &outError) { if (!_jsEngine || !_jsEngine->IsInitialized()) { outError = "JavaScript engine not initialized"; diff --git a/code/framework/src/scripting/resource/resource_manager.h b/code/framework/src/scripting/resource/resource_manager.h index a9c281063..9ec26e443 100644 --- a/code/framework/src/scripting/resource/resource_manager.h +++ b/code/framework/src/scripting/resource/resource_manager.h @@ -143,6 +143,24 @@ namespace Framework::Scripting { */ ResourceOperationResult ReloadResource(std::string_view name); + /** + * Re-parse a resource's package.json from disk and, if it was running, + * restart it (evicting its cached modules first). Picks up both code + * and manifest edits. Resources stopped beforehand stay stopped. + * Backs the `refresh` console command. + * @param name Resource name + * @return Result with the list of affected resources + */ + ResourceOperationResult RefreshResource(std::string_view name); + + /** + * Re-scan the resources directory, re-parse every known resource, and + * restart exactly those that were running. Newly discovered resources + * are registered but left stopped. Backs the `refreshall` command. + * @return Result with the list of affected resources + */ + ResourceOperationResult RefreshAll(); + // Registry Queries /** @@ -325,6 +343,10 @@ namespace Framework::Scripting { // Build dependency graph from discovered resources void BuildDependencyGraph(); + // Scan the resources directory for resources not already registered and + // register them. Returns the names added. Used by RefreshAll. + std::vector RescanResources(); + // Validate all dependencies can be satisfied bool ValidateDependencies(std::string &outError) const; From 137850386252232292c7d82e2d01e64aa3ff84fe Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:49:22 +0200 Subject: [PATCH 03/11] Scripting: add dev-mode file watcher for hot-reload Auto-trigger RefreshResource when a running resource's files change on disk, so editing a script reloads it without a console command. - ResourceManagerConfig gains devMode + fileWatchIntervalMs. - ProcessFileWatch (called from the scripting update tick) polls each running resource's newest file mtime, throttled by the interval, and hot-reloads on change. Portable mtime polling (no OS-specific watch API); skips node_modules/.git so big trees aren't walked every tick. - devMode plumbed via InstanceOptions.developmentMode -> ServerScriptingModule::SetDevMode -> ResourceManagerConfig. Off by default; production unaffected. --- .../src/integrations/server/instance.cpp | 1 + .../src/integrations/server/instance.h | 4 + .../integrations/server/scripting/module.cpp | 12 +++ .../integrations/server/scripting/module.h | 7 ++ .../scripting/resource/resource_manager.cpp | 89 +++++++++++++++++++ .../src/scripting/resource/resource_manager.h | 19 ++++ 6 files changed, 132 insertions(+) diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 9414026ff..d9c78279f 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -152,6 +152,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"); } diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index b6792d9f9..49437a68a 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -45,6 +45,10 @@ namespace Framework::Integrations::Server { std::string modConfigFile = "server.json"; std::string resourcesPath = "resources"; + // Development mode: watch resource files and hot-reload on change. + // Leave off in production. + bool developmentMode = false; + // networked game metadata (required) std::string gameName; std::string gameVersion; diff --git a/code/framework/src/integrations/server/scripting/module.cpp b/code/framework/src/integrations/server/scripting/module.cpp index 7157f8b1b..195f05126 100644 --- a/code/framework/src/integrations/server/scripting/module.cpp +++ b/code/framework/src/integrations/server/scripting/module.cpp @@ -56,6 +56,7 @@ namespace Framework::Integrations::Server::Scripting { config.resourcesPath = _resourcesPath; config.isClient = false; config.cascadeStopDependents = true; + config.devMode = _devMode; _resourceManager = std::make_unique( _nodeEngine.get(), config); @@ -180,6 +181,8 @@ namespace Framework::Integrations::Server::Scripting { if (_resourceManager) { // Process scheduled restarts _resourceManager->ProcessScheduledRestarts(); + // Dev-mode: poll resource files and hot-reload on change + _resourceManager->ProcessFileWatch(); } // Process pending message responses @@ -204,6 +207,15 @@ namespace Framework::Integrations::Server::Scripting { } } + void ServerScriptingModule::SetDevMode(bool enabled) { + _devMode = enabled; + if (_resourceManager) { + Framework::Scripting::ResourceManagerConfig config = _resourceManager->GetConfig(); + config.devMode = enabled; + _resourceManager->SetConfig(config); + } + } + std::vector ServerScriptingModule::GetClientResourceList() const { std::vector result; diff --git a/code/framework/src/integrations/server/scripting/module.h b/code/framework/src/integrations/server/scripting/module.h index 182bc7ce7..a339a276b 100644 --- a/code/framework/src/integrations/server/scripting/module.h +++ b/code/framework/src/integrations/server/scripting/module.h @@ -89,6 +89,12 @@ namespace Framework::Integrations::Server::Scripting { */ std::string GetResourcesPath() const { return _resourcesPath; } + /** + * Enable/disable development mode (file-watch hot-reload). Must be set + * before Init(); also updates an already-created resource manager. + */ + void SetDevMode(bool enabled); + /** * Get list of resources to send to clients. * Only includes resources with client entry points defined. @@ -110,6 +116,7 @@ namespace Framework::Integrations::Server::Scripting { std::unique_ptr _resourceManager; std::string _resourcesPath = "resources"; + bool _devMode = false; }; } // namespace Framework::Integrations::Server::Scripting diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index 32ebc1a1e..3826210a6 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -12,6 +12,8 @@ #include #include +#include +#include #include #include #include @@ -39,6 +41,46 @@ namespace Framework::Scripting { } return true; } + + // Newest last-write-time across all files under a resource directory, + // as implementation-defined clock ticks (monotonic for comparison on a + // given platform). Returns 0 if the directory can't be scanned. + int64_t ComputeResourceMTime(const std::string &path) { + std::error_code ec; + int64_t newest = 0; + std::filesystem::recursive_directory_iterator it( + path, std::filesystem::directory_options::skip_permission_denied, ec); + const std::filesystem::recursive_directory_iterator end; + if (ec) { + return newest; + } + for (; it != end; it.increment(ec)) { + if (ec) { + break; + } + // Don't descend into dependency/VCS trees — they're large and + // not hand-edited, so polling them every tick is wasted work. + if (it->is_directory(ec) && !ec) { + const auto dirName = it->path().filename().string(); + if (dirName == "node_modules" || dirName == ".git") { + it.disable_recursion_pending(); + } + continue; + } + if (!it->is_regular_file(ec) || ec) { + continue; + } + auto t = std::filesystem::last_write_time(it->path(), ec); + if (ec) { + continue; + } + int64_t ticks = static_cast(t.time_since_epoch().count()); + if (ticks > newest) { + newest = ticks; + } + } + return newest; + } } // anonymous namespace ResourceManager::ResourceManager(Engine *jsEngine, const ResourceManagerConfig &config) @@ -971,6 +1013,53 @@ namespace Framework::Scripting { } } + void ResourceManager::ProcessFileWatch() { + if (!_config.devMode) { + return; + } + + // Throttle polls to the configured interval. + const auto now = std::chrono::steady_clock::now(); + if (_lastFileWatchPoll.time_since_epoch().count() != 0) { + const auto elapsed = std::chrono::duration_cast( + now - _lastFileWatchPoll).count(); + if (elapsed < _config.fileWatchIntervalMs) { + return; + } + } + _lastFileWatchPoll = now; + + for (const auto &name : GetRunningResourceNames()) { + std::string path; + { + std::scoped_lock lock(_resourcesMutex); + auto it = _resources.find(name); + if (it != _resources.end() && it->second) { + path = it->second->GetPath(); + } + } + if (path.empty()) { + continue; + } + + const int64_t current = ComputeResourceMTime(path); + auto snapIt = _watchSnapshots.find(name); + if (snapIt == _watchSnapshots.end()) { + // First time we've seen this resource: seed, don't reload. + _watchSnapshots[name] = current; + continue; + } + if (current > snapIt->second) { + Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) + ->info("Detected change in resource '{}', hot-reloading", name); + // Update before reloading so a reload-induced rescan next tick + // doesn't re-trigger on the same change. + snapIt->second = current; + RefreshResource(name); + } + } + } + void ResourceManager::FireOnResourceStarted(const std::string &name) { if (_onResourceStarted) { _onResourceStarted(name); diff --git a/code/framework/src/scripting/resource/resource_manager.h b/code/framework/src/scripting/resource/resource_manager.h index 9ec26e443..a4a75e93a 100644 --- a/code/framework/src/scripting/resource/resource_manager.h +++ b/code/framework/src/scripting/resource/resource_manager.h @@ -47,6 +47,13 @@ namespace Framework::Scripting { // Whether to warn (instead of error) when a dependency is missing bool warnOnMissingDependency = false; + + // Development mode: poll resource files for changes and auto-reload. + // Should stay off in production (auto-reload is a dev convenience). + bool devMode = false; + + // Minimum interval between file-change polls, in milliseconds. + int fileWatchIntervalMs = 1000; }; /** @@ -333,6 +340,13 @@ namespace Framework::Scripting { */ void ProcessScheduledRestarts(); + /** + * In dev mode, poll running resources' files for changes (throttled by + * fileWatchIntervalMs) and hot-reload any that changed on disk. No-op + * when devMode is disabled. Call from the scripting update tick. + */ + void ProcessFileWatch(); + private: // Internal resource access (mutable) Resource *GetResourceMutable(std::string_view name); @@ -396,6 +410,11 @@ namespace Framework::Scripting { std::vector _scheduledRestarts; mutable std::mutex _scheduledRestartsMutex; + // Dev-mode file watcher state (accessed only from the update thread). + // Maps resource name -> newest file mtime seen (impl-defined ticks). + std::map _watchSnapshots; + std::chrono::steady_clock::time_point _lastFileWatchPoll{}; + // Events instance owned by this manager Events _events; }; From 569e916d5890825870316b910a28a8e4c5e9193c Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:05:22 +0200 Subject: [PATCH 04/11] Scripting: cancel a resource's timers when it stops In the shared runtime, re-running a resource on hot-reload left its old setTimeout/setInterval callbacks firing and duplicated them each reload. Add Engine::ClearResourceTimers(name), called from StopResource after event cleanup: - V8Engine tags each timer with the owning resource (resolved like event handlers: callback script origin, then context, then stack) and cancels matching timers. - NodeEngine wraps the global setTimeout/setInterval/clear* in a bootstrap shim that tracks handles per resource (attributed via privileged __fw_ownerOf) and cancels them via __fw_clearResourceTimers. The real timer handle is returned unchanged so clearTimeout/unref/promisify still work; one-shot timers untrack themselves on fire. Framework event listeners were already cleaned via Events::CleanupResource; this closes the raw-timer leak. Arbitrary EventEmitter listeners still need manual cleanup via resourceStop (documented limitation). --- .../integrations/server/scripting/module.cpp | 1 + code/framework/src/scripting/engine.h | 9 ++ code/framework/src/scripting/node_engine.cpp | 86 +++++++++++++++++++ code/framework/src/scripting/node_engine.h | 17 ++++ .../scripting/resource/resource_manager.cpp | 6 ++ code/framework/src/scripting/v8_engine.cpp | 11 +++ code/framework/src/scripting/v8_engine.h | 6 ++ .../src/scripting/v8_engine_callbacks.h | 19 +++- 8 files changed, 154 insertions(+), 1 deletion(-) diff --git a/code/framework/src/integrations/server/scripting/module.cpp b/code/framework/src/integrations/server/scripting/module.cpp index 195f05126..297053dc2 100644 --- a/code/framework/src/integrations/server/scripting/module.cpp +++ b/code/framework/src/integrations/server/scripting/module.cpp @@ -78,6 +78,7 @@ namespace Framework::Integrations::Server::Scripting { v8::Context::Scope contextScope(context); _nodeEngine->InstallUncaughtExceptionHandler(_resourcesPath); + _nodeEngine->InstallResourceTimerTracking(); } Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING)->info( diff --git a/code/framework/src/scripting/engine.h b/code/framework/src/scripting/engine.h index 5b53facaa..9381cffa9 100644 --- a/code/framework/src/scripting/engine.h +++ b/code/framework/src/scripting/engine.h @@ -74,6 +74,15 @@ namespace Framework::Scripting { */ virtual void EvictModulesUnderPath(const std::string &rootPath) {} + /** + * Cancel any timers (setTimeout/setInterval) still owned by a resource + * when it stops. In a shared runtime, re-running a resource otherwise + * leaves its old intervals firing and duplicates them on reload. + * Default is a no-op. Call only after the resource has been stopped. + * @param resourceName Name of the resource whose timers to cancel + */ + virtual void ClearResourceTimers(const std::string &resourceName) {} + /** * Register framework SDK bindings. * Called after Init() to set up Framework.* APIs. diff --git a/code/framework/src/scripting/node_engine.cpp b/code/framework/src/scripting/node_engine.cpp index 4975efd89..f9268d8e4 100644 --- a/code/framework/src/scripting/node_engine.cpp +++ b/code/framework/src/scripting/node_engine.cpp @@ -9,6 +9,7 @@ #include "node_engine.h" #include "engine_helpers.h" #include "builtins/messages.h" +#include "resource/resource_manager.h" #include @@ -222,6 +223,51 @@ namespace Framework::Scripting { " } catch (e) { return 0; }" " }, writable: false, configurable: false, enumerable: false" "});" + // Per-resource timer tracking: wrap the global timer functions so a + // resource's setTimeout/setInterval can be cancelled when it stops + // (a shared runtime would otherwise keep firing them across reloads). + // Timers are attributed to a resource via __fw_ownerOf (installed + // from C++ after Init). The real timer handle is returned unchanged, + // so user clearTimeout/unref and util.promisify keep working. + "(function(){" + " const reg = new Map();" + " const _st = globalThis.setTimeout, _si = globalThis.setInterval," + " _ct = globalThis.clearTimeout, _ci = globalThis.clearInterval;" + " function ownerSet(n){ let s = reg.get(n); if (!s) { s = new Set(); reg.set(n, s); } return s; }" + " function ownerOf(fn){ try { return (typeof fn === 'function' && typeof globalThis.__fw_ownerOf === 'function') ? globalThis.__fw_ownerOf(fn) : ''; } catch (e) { return ''; } }" + " globalThis.setTimeout = function(fn){" + " const name = ownerOf(fn);" + " let handle;" + " const a = Array.prototype.slice.call(arguments);" + " if (typeof fn === 'function' && name) {" + " a[0] = function(){ const s = reg.get(name); if (s) s.delete(handle); return fn.apply(this, arguments); };" + " }" + " handle = _st.apply(this, a);" + " if (name) ownerSet(name).add(handle);" + " return handle;" + " };" + " globalThis.setInterval = function(fn){" + " const name = ownerOf(fn);" + " const handle = _si.apply(this, arguments);" + " if (name) ownerSet(name).add(handle);" + " return handle;" + " };" + " globalThis.clearTimeout = function(h){ for (const s of reg.values()) s.delete(h); return _ct(h); };" + " globalThis.clearInterval = function(h){ for (const s of reg.values()) s.delete(h); return _ci(h); };" + " try {" + " const PCS = Symbol.for('nodejs.util.promisify.custom');" + " if (_st[PCS]) globalThis.setTimeout[PCS] = _st[PCS];" + " if (_si[PCS]) globalThis.setInterval[PCS] = _si[PCS];" + " } catch (e) {}" + " Object.defineProperty(globalThis, '__fw_clearResourceTimers', {" + " value: function(name){" + " const s = reg.get(name); if (!s) return 0;" + " let n = 0;" + " for (const h of s) { try { _ct(h); _ci(h); } catch (e) {} n++; }" + " reg.delete(name); return n;" + " }, writable: false, configurable: false, enumerable: false" + " });" + "})();" "process.setUncaughtExceptionCaptureCallback((err) => {" " try {" " const msg = err instanceof Error ? (err.stack || err.message) : String(err);" @@ -366,6 +412,17 @@ namespace Framework::Scripting { Execute(code, ""); } + void NodeEngine::ClearResourceTimers(const std::string &resourceName) { + if (!_initialized || resourceName.empty()) { + return; + } + std::string escaped = EscapeForSingleQuotedJSString(resourceName); + std::string code = + "if (typeof globalThis.__fw_clearResourceTimers === 'function')" + " globalThis.__fw_clearResourceTimers('" + escaped + "');"; + Execute(code, ""); + } + v8::Local NodeEngine::GetContext() const { if (_setup) { return _setup->context(); @@ -391,6 +448,35 @@ namespace Framework::Scripting { context->Global()->Set(context, key, fn).Check(); } + void NodeEngine::InstallResourceTimerTracking() { + if (!_setup) { + return; + } + v8::Local context = _setup->context(); + v8::Local data = v8::External::New(_isolate, this); + v8::Local tmpl = v8::FunctionTemplate::New( + _isolate, OnTimerOwnerLookup, data); + v8::Local fn = tmpl->GetFunction(context).ToLocalChecked(); + + v8::Local key = v8::String::NewFromUtf8( + _isolate, "__fw_ownerOf").ToLocalChecked(); + context->Global()->Set(context, key, fn).Check(); + } + + void NodeEngine::OnTimerOwnerLookup(const v8::FunctionCallbackInfo &info) { + v8::Isolate *isolate = info.GetIsolate(); + auto *engine = static_cast( + v8::Local::Cast(info.Data())->Value()); + + std::string name; + if (info.Length() > 0 && info[0]->IsFunction() && engine->GetResourceManager()) { + name = engine->GetResourceManager()->GetResourceNameFromFunction( + isolate, info[0].As()); + } + info.GetReturnValue().Set( + v8::String::NewFromUtf8(isolate, name.c_str()).ToLocalChecked()); + } + void NodeEngine::OnUncaughtError(const v8::FunctionCallbackInfo &info) { v8::Isolate *isolate = info.GetIsolate(); auto *engine = static_cast( diff --git a/code/framework/src/scripting/node_engine.h b/code/framework/src/scripting/node_engine.h index 6ce46ae00..d28ef32d2 100644 --- a/code/framework/src/scripting/node_engine.h +++ b/code/framework/src/scripting/node_engine.h @@ -93,6 +93,19 @@ namespace Framework::Scripting { */ void EvictModulesUnderPath(const std::string &rootPath) override; + /** + * Cancel timers created by the named resource (via the bootstrap timer + * tracking shim). Call after the resource has stopped. + */ + void ClearResourceTimers(const std::string &resourceName) override; + + /** + * Install the privileged __fw_ownerOf(fn) helper used by the bootstrap + * timer-tracking shim to attribute timers to resources. Must be called + * once after Init(), with V8 scopes active. + */ + void InstallResourceTimerTracking(); + /** * Process pending Node.js events (non-blocking). * Call this from game loop to process async operations. @@ -164,6 +177,10 @@ namespace Framework::Scripting { static void OnUncaughtError(const v8::FunctionCallbackInfo &info); + // Privileged JS helper: returns the resource name owning a function, + // resolved from its script origin. Backs the timer-tracking shim. + static void OnTimerOwnerLookup(const v8::FunctionCallbackInfo &info); + NodeEngineOptions _options; static std::unique_ptr _platform; diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index 3826210a6..21bc1c77e 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -406,6 +406,12 @@ namespace Framework::Scripting { // Call cleanup (removes handlers) CallResourceStop(name); + // Cancel timers the resource left running (raw setTimeout/setInterval), + // which the shared runtime would otherwise keep firing across reloads. + if (_jsEngine) { + _jsEngine->ClearResourceTimers(std::string(name)); + } + // Clear exports resource->ClearExports(); diff --git a/code/framework/src/scripting/v8_engine.cpp b/code/framework/src/scripting/v8_engine.cpp index 219761be5..06f97be76 100644 --- a/code/framework/src/scripting/v8_engine.cpp +++ b/code/framework/src/scripting/v8_engine.cpp @@ -125,6 +125,17 @@ namespace Framework::Scripting { _requireDataStore.end()); } + void V8Engine::ClearResourceTimers(const std::string &resourceName) { + if (resourceName.empty()) { + return; + } + for (auto &timer : _timers) { + if (timer && timer->ownerResource == resourceName) { + timer->cancelled = true; + } + } + } + void V8Engine::Shutdown() { if (!_initialized) { return; diff --git a/code/framework/src/scripting/v8_engine.h b/code/framework/src/scripting/v8_engine.h index e3ec70a1b..a7328eb6f 100644 --- a/code/framework/src/scripting/v8_engine.h +++ b/code/framework/src/scripting/v8_engine.h @@ -91,6 +91,11 @@ namespace Framework::Scripting { */ void EvictModulesUnderPath(const std::string &rootPath) override; + /** + * Cancel all timers created by the named resource. Call after stop. + */ + void ClearResourceTimers(const std::string &resourceName) override; + // Module loader internals (used by require callback) v8::MaybeLocal LoadModule(std::string_view requestedPath, std::string_view referencingDir); @@ -105,6 +110,7 @@ namespace Framework::Scripting { std::chrono::steady_clock::time_point fireTime; int intervalMs = 0; // 0 = setTimeout, >0 = setInterval bool cancelled = false; + std::string ownerResource; // resource that created the timer, for cleanup on stop }; static constexpr uint32_t kMaxTimers = 10000; diff --git a/code/framework/src/scripting/v8_engine_callbacks.h b/code/framework/src/scripting/v8_engine_callbacks.h index 778d67adc..e95d218e9 100644 --- a/code/framework/src/scripting/v8_engine_callbacks.h +++ b/code/framework/src/scripting/v8_engine_callbacks.h @@ -13,6 +13,7 @@ // Included only by v8_engine.cpp — not part of the public API. #include "v8_engine.h" +#include "resource/resource_manager.h" #include @@ -138,12 +139,28 @@ namespace Framework::Scripting::V8EngineCallbacks { if (delayMs < 0) delayMs = 0; } + v8::Local callbackFn = v8::Local::Cast(args[0]); + auto entry = std::make_unique(); - entry->callback.Reset(isolate, v8::Local::Cast(args[0])); + entry->callback.Reset(isolate, callbackFn); entry->fireTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(delayMs); entry->intervalMs = data->isInterval ? (delayMs > 0 ? delayMs : 1) : 0; entry->cancelled = false; + // Attribute the timer to its owning resource (for cleanup on stop), + // mirroring how the event system attributes async handlers: prefer the + // callback's script origin, then the current context, then the stack. + if (auto *mgr = data->engine->GetResourceManager()) { + std::string owner = mgr->GetResourceNameFromFunction(isolate, callbackFn); + if (owner.empty()) { + owner = mgr->GetCurrentResourceContext(); + } + if (owner.empty()) { + owner = mgr->GetResourceContextFromStack(isolate); + } + entry->ownerResource = std::move(owner); + } + // Capture extra arguments for (int i = 2; i < args.Length(); ++i) { v8::Global arg; From ee2175666234caffd9421af209dd4b5ee0a589af Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:48:07 +0200 Subject: [PATCH 05/11] Scripting/Server: broadcast hot-reloaded resources to clients Server half of client-side hot-reload propagation. - New ResourceRefresh RPC (server -> client) carrying changed client resource names/versions, mirroring ServerResources serialization. - ResourceManager gains SetOnResourceReloaded, fired by RefreshResource and RefreshAll (distinct from start/stop so integrations react only to reloads, not normal startup). - Server Instance hooks it: rebuilds the asset streamer's upload list (ClearUploads + InitAssetStreamer) so the changed files get fresh hashes -- DirectoryDeltaTransfer compares stored hashes, so without this the edited file would look up-to-date and never re-send -- then BroadcastRPC(ResourceRefresh) for resources with a client entry point. Reuses existing MafiaNet streamer (ClearUploads/AddFile/delta transfer), no new transport. --- .../src/integrations/server/instance.cpp | 44 ++++++++++++++++ .../src/integrations/server/instance.h | 2 + .../src/networking/rpc/resource_refresh.h | 51 +++++++++++++++++++ .../scripting/resource/resource_manager.cpp | 12 +++++ .../src/scripting/resource/resource_manager.h | 10 ++++ 5 files changed, 119 insertions(+) create mode 100644 code/framework/src/networking/rpc/resource_refresh.h diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index d9c78279f..867fa38b5 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -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" @@ -178,6 +179,12 @@ namespace Framework::Integrations::Server { // Initialize asset streamer (needs discovered resources to know client files) InitAssetStreamer(); + // Notify connected clients when a client resource is hot-reloaded so + // they can re-sync its files and restart it (dev mode). + _scriptingModule->GetResourceManager()->SetOnResourceReloaded([this](const std::string &name) { + BroadcastResourceRefresh(name); + }); + // Start all resources (ES modules load asynchronously via normal Update cycle) auto startResult = _scriptingModule->GetResourceManager()->StartAll(); if (!startResult) { @@ -432,6 +439,43 @@ 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 asset streamer's upload list so the changed files get + // fresh hashes. The delta transfer compares stored hashes, so without + // this the edited file would look up-to-date and never be re-sent. + if (auto *streamer = net->GetAssetStreamer()) { + streamer->ClearUploads(); + } + InitAssetStreamer(); + + Framework::Networking::RPC::ResourceRefresh refresh; + refresh.resources.push_back({resource->GetName(), resource->GetVersion()}); + net->BroadcastRPC(refresh); + + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Broadcasting hot-reload of resource '{}' to clients", name); + } + void Instance::InitCommandListener() { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Setting up command listener and processor..."); diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index 49437a68a..42a501bf0 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -123,6 +123,8 @@ namespace Framework::Integrations::Server { void InitEndpoints(); void InitNetworkingMessages(); void InitAssetStreamer(); + // Re-sync a hot-reloaded client resource to connected clients. + void BroadcastResourceRefresh(const std::string &name); void InitCommandListener(); bool LoadConfigFromJSON(); void RegisterScriptingBuiltins(Framework::Scripting::Engine *); diff --git a/code/framework/src/networking/rpc/resource_refresh.h b/code/framework/src/networking/rpc/resource_refresh.h new file mode 100644 index 000000000..5d07c16be --- /dev/null +++ b/code/framework/src/networking/rpc/resource_refresh.h @@ -0,0 +1,51 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2024, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "rpc.h" +#include "server_resources.h" // ResourceInfo + +#include +#include +#include +#include + +namespace Framework::Networking::RPC { + // Server -> client, dev hot-reload only: these client resources changed on + // the server and were reloaded. The client re-syncs their files from the + // asset streamer (delta transfer) and restarts them in place. + struct ResourceRefresh { + static constexpr const char *kIdentifier = "Framework::ResourceRefresh"; + static constexpr uint16_t kMaxResources = 1000; // bound untrusted input + + std::vector resources; + + void Serialize(MafiaNet::BitStream *bs, bool write) { + if (write && resources.size() > std::numeric_limits::max()) { + Logging::GetLogger(FRAMEWORK_INNER_NETWORKING)->error("ResourceRefresh holds {} resources, exceeding the wire limit; truncating", resources.size()); + } + + uint16_t count = static_cast(std::min(resources.size(), std::numeric_limits::max())); + bs->Serialize(write, count); + if (!write) { + resources.clear(); + resources.resize(std::min(count, kMaxResources)); + } + for (uint16_t i = 0; i < count; ++i) { + // Entries past the sane cap are still consumed so the bitstream stays aligned. + if (!write && i >= kMaxResources) { + ResourceInfo discard; + discard.Serialize(bs, write); + continue; + } + resources[i].Serialize(bs, write); + } + } + }; +} // namespace Framework::Networking::RPC diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index 21bc1c77e..a6408f9c7 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -520,6 +520,7 @@ namespace Framework::Scripting { affected.insert(affected.end(), a.begin(), a.end()); } } + FireOnResourceReloaded(newName); return ResourceOperationResult::Ok(affected); } @@ -571,6 +572,7 @@ namespace Framework::Scripting { if (result) { const auto &a = result.GetValue(); affected.insert(affected.end(), a.begin(), a.end()); + FireOnResourceReloaded(name); } } return ResourceOperationResult::Ok(affected); @@ -770,6 +772,10 @@ namespace Framework::Scripting { _onResourceStateChanged = std::move(callback); } + void ResourceManager::SetOnResourceReloaded(ResourceEventCallback callback) { + _onResourceReloaded = std::move(callback); + } + Engine *ResourceManager::GetJSEngine() const { return _jsEngine; } @@ -1078,6 +1084,12 @@ namespace Framework::Scripting { } } + void ResourceManager::FireOnResourceReloaded(const std::string &name) { + if (_onResourceReloaded) { + _onResourceReloaded(name); + } + } + void ResourceManager::FireOnResourceError(const std::string &name, const std::string &error) { if (_onResourceError) { _onResourceError(name, error); diff --git a/code/framework/src/scripting/resource/resource_manager.h b/code/framework/src/scripting/resource/resource_manager.h index a4a75e93a..669dffa3a 100644 --- a/code/framework/src/scripting/resource/resource_manager.h +++ b/code/framework/src/scripting/resource/resource_manager.h @@ -240,6 +240,14 @@ namespace Framework::Scripting { */ void SetOnResourceStateChanged(ResourceStateCallback callback); + /** + * Set callback for when a resource is hot-reloaded (refreshed from + * disk and restarted). Distinct from start/stop: fired only by + * RefreshResource/RefreshAll, so integrations can react to reloads + * (e.g. notify connected clients) without reacting to normal startup. + */ + void SetOnResourceReloaded(ResourceEventCallback callback); + // JS Engine Access /** @@ -370,6 +378,7 @@ namespace Framework::Scripting { // Fire resource lifecycle events void FireOnResourceStarted(const std::string &name); void FireOnResourceStopped(const std::string &name); + void FireOnResourceReloaded(const std::string &name); void FireOnResourceError(const std::string &name, const std::string &error); void FireOnResourceStateChanged(const std::string &name, ResourceState oldState, ResourceState newState); @@ -397,6 +406,7 @@ namespace Framework::Scripting { ResourceEventCallback _onResourceStopped; ResourceErrorCallback _onResourceError; ResourceStateCallback _onResourceStateChanged; + ResourceEventCallback _onResourceReloaded; // Current resource context std::string _currentResourceContext; From 289c7a7c9b30ff6a333352d1acad70ac077820a6 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:48:07 +0200 Subject: [PATCH 06/11] Client: re-sync and refresh hot-reloaded resources Client half of hot-reload propagation. - Register the ResourceRefresh RPC handler. On receipt, store the changed resource names and trigger a targeted delta re-sync that, unlike the connect-time download, does NOT stop all resources or tear down web views. - When that download completes and the scripting module is already initialized, OnAssetsDownloaded refreshes just the flagged resources via the client ResourceManager's RefreshResource (module eviction + timer cancel + manifest re-parse + restart). A refresh racing an initial connect falls through to the normal full init. MAJOR: netcode change, requires matching client+server build. Not yet verified on a live 2-machine connection. --- .../src/integrations/client/instance.cpp | 71 +++++++++++++++++++ .../src/integrations/client/instance.h | 6 ++ 2 files changed, 77 insertions(+) diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index 3bf7dfbad..f46288c79 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -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,24 @@ 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([this](const Framework::Networking::RPC::ResourceRefresh &payload, MafiaNet::Packet *) { + if (payload.resources.empty()) { + return; + } + // Ignore until we've finished connecting and have a running scripting + // module. Otherwise we'd cancel the in-flight initial asset download; + // that initial sync already fetches the current (reloaded) files. + auto *sm = GetScriptingModule(); + if (!sm || !sm->GetScriptingEngine() || !sm->GetScriptingEngine()->IsInitialized()) { + return; + } + _pendingRefreshResources = payload.resources; + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("Server hot-reloaded {} resource(s); re-syncing", _pendingRefreshResources.size()); + SyncResourceUpdatesFromServer(); + }); + // 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 +503,64 @@ 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 this does NOT stop all + // resources or tear down web views — only the changed resources are + // refreshed once the (delta) download completes. The asset cache path + // is already configured from the initial 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: the scripting module is already running and the + // server flagged specific resources. Refresh just those (their + // files were re-synced above) instead of re-initializing the whole + // module and restarting everything. + if (scriptingModule && scriptingModule->GetScriptingEngine() + && scriptingModule->GetScriptingEngine()->IsInitialized() + && !_pendingRefreshResources.empty()) { + if (auto *rm = scriptingModule->GetResourceManager()) { + for (const auto &res : _pendingRefreshResources) { + auto result = rm->RefreshResource(res.name); + if (!result) { + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Failed to refresh client resource '{}': {}", res.name, result.GetError()); + } + } + } + _pendingRefreshResources.clear(); + return; + } + // A refresh that raced an initial connect falls through to the full + // init below, which loads the just-synced files anyway. + _pendingRefreshResources.clear(); + if (scriptingModule) { // Set resource cache path before init scriptingModule->SetResourceCachePath(GetAssetCachePath()); diff --git a/code/framework/src/integrations/client/instance.h b/code/framework/src/integrations/client/instance.h index 88a34bb50..a6c02d54a 100644 --- a/code/framework/src/integrations/client/instance.h +++ b/code/framework/src/integrations/client/instance.h @@ -109,6 +109,10 @@ namespace Framework::Integrations::Client { // Pending resources from server (stored here to survive scripting module reset) std::vector _pendingServerResources; + // Client resources the server hot-reloaded; refreshed after the next + // asset re-sync completes (dev mode). Empty on a normal connect. + std::vector _pendingRefreshResources; + // Handshake state carried from ServerResources until the ReadyEvent spawn barrier completes. int _readyEventId {}; float _serverTickRate {}; @@ -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 *); From eb1b9022466d6fa691ad6045e339819ced9fef63 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:12:26 +0200 Subject: [PATCH 07/11] Docs: document resource hot-reload Cover enabling (developmentMode watcher + refresh/refreshall commands), the reload sequence (stop -> timer cancel -> module eviction -> manifest re-parse -> restart), client propagation via ResourceRefresh + asset streamer rebuild, and limitations (CJS-only eviction, EventEmitter/ require('timers') leaks, new-resource propagation, MAJOR netcode). --- docs/resource_hot_reload.md | 87 +++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 docs/resource_hot_reload.md diff --git a/docs/resource_hot_reload.md b/docs/resource_hot_reload.md new file mode 100644 index 000000000..2c9650b59 --- /dev/null +++ b/docs/resource_hot_reload.md @@ -0,0 +1,87 @@ +# Resource Hot-Reload + +The scripting layer can reload a JavaScript resource from disk without +restarting the server, and propagate that reload to connected clients. This is +a development aid: edit a resource's `.js`, and the running resource picks up +the change. + +## Enabling + +Hot-reload triggers come in two forms: + +- **Console commands** (always available): `refresh ` and + `refreshall`, registered on the server `Instance`. +- **Automatic file watcher** (opt-in): set `InstanceOptions.developmentMode = + true` on the server. The watcher polls running resources' files and reloads + any that change. It is **off by default** — leave it off in production. + +```cpp +Framework::Integrations::Server::InstanceOptions opts; +opts.developmentMode = true; // enable the file watcher +``` + +The watcher interval defaults to 1s (`ResourceManagerConfig::fileWatchIntervalMs`) +and skips `node_modules`/`.git`. + +## What a reload does + +`ResourceManager::RefreshResource(name)` (and `RefreshAll`): + +1. **Stops** the resource (and any dependents that cascade), firing + `resourceStop` and running `Events::CleanupResource` so the resource's + framework event listeners are removed. +2. **Cancels the resource's timers.** `Engine::ClearResourceTimers` cancels any + `setTimeout`/`setInterval` the resource created, so a shared runtime doesn't + keep firing — or duplicate — them across reloads. +3. **Evicts the resource's cached modules.** `Engine::EvictModulesUnderPath` + removes the resource's entries from the module cache (Node `require.cache` + on the server, the V8 module cache on the client), so re-execution re-reads + the edited files instead of returning stale exports. +4. **Re-parses `package.json`** from disk (manifest edits — entry points, + dependencies — take effect) and rebuilds the dependency graph. +5. **Restarts** the resource and the dependents that were stopped. + +`RefreshAll` additionally rescans the resources directory and registers +newly-added resource directories (left stopped — `start` them explicitly). + +## Client propagation + +When a resource with a client entry point reloads, the server notifies clients +so they re-sync and restart it in place: + +1. `RefreshResource`/`RefreshAll` fire `ResourceManager::SetOnResourceReloaded`. +2. The server `Instance` rebuilds the asset streamer's upload list + (`ClearUploads` + `InitAssetStreamer`). This is required: MafiaNet's + `DirectoryDeltaTransfer` compares the file hashes captured when files were + added, so without rebuilding, an edited file looks up-to-date and is never + re-sent. +3. The server broadcasts a `ResourceRefresh` RPC (changed resource + names/versions). +4. The client re-runs a **targeted** delta download (only changed files + transfer; unlike the connect-time download it does not stop all resources), + then calls its own `RefreshResource` for the flagged resources. + +Clients that haven't finished connecting ignore `ResourceRefresh` — their +initial asset sync already fetches the current files. + +## Limitations + +- **CommonJS only.** Module-cache eviction covers the framework's CJS load path. + Resources loaded via dynamic ESM `import()` are not in `require.cache` and + won't be re-read on reload. +- **Raw `EventEmitter` listeners leak.** Only framework event listeners + (`on(...)`) and engine timers are cleaned up. Listeners a resource adds to its + own emitters (or `process.on`) must be removed in a `resourceStop` handler. +- **`require('timers')` bypasses timer tracking.** Timer cleanup wraps the + global `setTimeout`/`setInterval`; code importing the `timers` module + directly is not tracked. +- **Only changed existing resources propagate to clients.** A brand-new resource + added at runtime reaches already-connected clients on their next full connect, + not via `ResourceRefresh`. + +## Versioning + +Client propagation is a netcode change (new RPC, both client and server) — a +**MAJOR** change requiring matching client and server builds. The server-only +pieces (eviction, `refresh` commands, watcher, timer cleanup) do not affect the +wire protocol. From e2ae086266ddf5f2c48a500052fd28fdf906ce46 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:18:59 +0200 Subject: [PATCH 08/11] Scripting: align reload with FiveM (restart reloads code, ensure verb) Refinements from comparing to CitizenFX FiveM's resource model. - RestartResource now evicts the resource's modules between stop and start. In our shared runtime a plain stop+start re-executes against stale module caches and does NOT reload code; eviction makes restart reload code, matching per-resource-runtime engines (FiveM/MTASA) and giving error auto-restart a clean slate. ReloadResource collapses to RestartResource. - Add the 'ensure ' console command (FiveM's canonical start-or-reload verb). - Document the shared-runtime vs per-resource-runtime tradeoff and recommend per-resource isolation as the long-term direction. --- .../src/integrations/server/instance.cpp | 23 +++++++++++++ .../scripting/resource/resource_manager.cpp | 24 ++++++------- docs/resource_hot_reload.md | 34 +++++++++++++++++-- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 867fa38b5..f3b396ec7 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -554,6 +554,29 @@ namespace Framework::Integrations::Server { }, "Re-scan and reload all resources from disk"); + // FiveM-style start-or-reload: reload if running, start if stopped. The + // canonical reload verb operators coming from FiveM/MTASA expect. + _commandProcessor->RegisterCommand( + "ensure", {}, + [this](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: ensure "); + return; + } + auto res = rm->IsResourceRunning(args[0]) ? rm->RefreshResource(args[0]) : rm->StartResource(args[0]); + if (res) { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Ensured resource '{}'", args[0]); + } else { + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to ensure '{}': {}", args[0], res.GetError()); + } + }, + "Start or reload a resource: ensure "); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Command listener and processor initialized"); } diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index a6408f9c7..dddd58067 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -426,18 +426,12 @@ namespace Framework::Scripting { } ResourceOperationResult ResourceManager::RestartResource(std::string_view name) { - auto stopResult = StopResource(name); - if (!stopResult) { - return stopResult; - } - - return StartResource(name); - } - - ResourceOperationResult ResourceManager::ReloadResource(std::string_view name) { - // Capture the resource root before stopping so we can evict its cached - // modules. Without eviction, re-executing the entry point returns stale - // require() exports and on-disk edits to non-entry files are ignored. + // Capture the resource root so we can evict its cached modules between + // stop and start. In a shared runtime a plain stop+start re-executes the + // entry against stale require()/module caches and does NOT pick up code + // changes; eviction makes "restart" actually reload code, matching + // per-resource-runtime engines (FiveM/MTASA) where restart inherently + // reloads. This also gives error auto-restart a clean module slate. std::string resourceRoot; { std::scoped_lock lock(_resourcesMutex); @@ -460,6 +454,12 @@ namespace Framework::Scripting { return StartResource(name); } + ResourceOperationResult ResourceManager::ReloadResource(std::string_view name) { + // RestartResource now evicts modules, so reload is just a restart that + // re-reads the resource's code from disk. + return RestartResource(name); + } + ResourceOperationResult ResourceManager::RefreshResource(std::string_view name) { std::string path; bool wasRunning = false; diff --git a/docs/resource_hot_reload.md b/docs/resource_hot_reload.md index 2c9650b59..8d4a348f6 100644 --- a/docs/resource_hot_reload.md +++ b/docs/resource_hot_reload.md @@ -9,8 +9,13 @@ the change. Hot-reload triggers come in two forms: -- **Console commands** (always available): `refresh ` and - `refreshall`, registered on the server `Instance`. +- **Console commands** (always available), registered on the server `Instance`: + - `ensure ` — start the resource if stopped, reload it if running. + The FiveM-style canonical reload verb. + - `refresh ` — reload a running resource (re-parsing its manifest); + leaves a stopped resource stopped. + - `refreshall` — re-scan the resources directory and reload everything that + was running. - **Automatic file watcher** (opt-in): set `InstanceOptions.developmentMode = true` on the server. The watcher polls running resources' files and reloads any that change. It is **off by default** — leave it off in production. @@ -79,6 +84,31 @@ initial asset sync already fetches the current files. added at runtime reaches already-connected clients on their next full connect, not via `ResourceRefresh`. +## Design notes (vs FiveM / MTASA) + +FiveM and MTASA run **each resource in its own script runtime** (Lua state / V8 +isolate / Mono domain). Reloading destroys and recreates that runtime, so +modules, timers, and event listeners are freed automatically — there is no +cache to evict or timer to cancel. + +This framework runs **one shared Node runtime** for all resources. Everything in +the reload sequence above (module eviction, timer cancellation, listener +cleanup) exists to compensate for that shared runtime. A consequence worth +knowing: a plain stop+start does **not** reload code here — the entry point +re-executes against stale module caches. `RestartResource` therefore evicts the +resource's modules between stop and start, so "restart" reloads code the way it +does in a per-resource-runtime engine. + +The robust long-term direction, if reload correctness becomes a recurring +concern, is **per-resource isolation** (a dedicated V8 context per resource): +reload would drop the context and rebuild it, eliminating the module-eviction, +timer-leak, and listener-leak classes at the source — the same property FiveM +and MTASA rely on. + +Where this framework is already ahead of FiveM: reloading a dependency restarts +the dependents that cascaded down (FiveM leaves them stopped — see its +`#TODO: restarting behavior of stopped dependencies at runtime`). + ## Versioning Client propagation is a netcode change (new RPC, both client and server) — a From b0285794f9a00acbcf6eb744b9a6658818915da3 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:05:57 +0200 Subject: [PATCH 09/11] Scripting: propagate runtime resource starts to clients Generalize client propagation so it also covers resources started at runtime, not just reloads (FiveM does this; we previously only pushed changed existing resources). - Server broadcasts on any post-boot StartResource via SetOnResourceStarted (gated by a _resourcesBooted latch so boot-time StartAll doesn't push; clients get the full list on connect). This uniformly covers reload restarts, error auto-restarts, and newly started resources. Removes the now-redundant OnResourceReloaded callback. - Client handler discovers a resource it doesn't know yet from the synced cache and starts it; otherwise reloads if running / starts if stopped. Known edge: a brand-new client resource with unmet dependencies isn't graph-resolved on the client (rare hot-add case). --- .../src/integrations/client/instance.cpp | 15 +++++++-- .../src/integrations/server/instance.cpp | 15 ++++++--- .../src/integrations/server/instance.h | 3 ++ .../scripting/resource/resource_manager.cpp | 12 ------- .../src/scripting/resource/resource_manager.h | 10 ------ docs/resource_hot_reload.md | 31 ++++++++++--------- 6 files changed, 43 insertions(+), 43 deletions(-) diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index f46288c79..2398ec1ce 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -548,9 +548,20 @@ namespace Framework::Integrations::Client { && !_pendingRefreshResources.empty()) { if (auto *rm = scriptingModule->GetResourceManager()) { for (const auto &res : _pendingRefreshResources) { - auto result = rm->RefreshResource(res.name); + // A resource the client doesn't know yet was started on + // the server at runtime: discover it from the just-synced + // cache before starting. + 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 refresh client resource '{}': {}", res.name, result.GetError()); + Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->warn("Failed to sync client resource '{}': {}", res.name, result.GetError()); } } } diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index f3b396ec7..634305837 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -179,10 +179,14 @@ namespace Framework::Integrations::Server { // Initialize asset streamer (needs discovered resources to know client files) InitAssetStreamer(); - // Notify connected clients when a client resource is hot-reloaded so - // they can re-sync its files and restart it (dev mode). - _scriptingModule->GetResourceManager()->SetOnResourceReloaded([this](const std::string &name) { - BroadcastResourceRefresh(name); + // Notify connected clients whenever a client resource starts at runtime + // (a hot-reload restart, an error auto-restart, or a newly started + // resource). Gated on the initial boot: during StartAll below no client + // is connected yet, and each client receives the full list on connect. + _scriptingModule->GetResourceManager()->SetOnResourceStarted([this](const std::string &name) { + if (_resourcesBooted) { + BroadcastResourceRefresh(name); + } }); // Start all resources (ES modules load asynchronously via normal Update cycle) @@ -190,6 +194,9 @@ namespace Framework::Integrations::Server { if (!startResult) { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to start resources: {}", startResult.GetError()); } + // Past this point, resource starts are runtime changes worth pushing to + // connected clients. + _resourcesBooted = true; Logging::GetLogger(FRAMEWORK_INNER_SERVER)->flush(); Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Host:\t{}", _opts.bindHost); diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index 42a501bf0..b670a8ff8 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -108,6 +108,9 @@ namespace Framework::Integrations::Server { class Instance : public Framework::Lifecycle { private: std::atomic _shuttingDown; + // True once the initial StartAll has run; gates runtime resource-start + // broadcasts to clients (boot-time starts aren't pushed). + bool _resourcesBooted = false; std::chrono::time_point _nextTick; InstanceOptions _opts; diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index dddd58067..26790574a 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -520,7 +520,6 @@ namespace Framework::Scripting { affected.insert(affected.end(), a.begin(), a.end()); } } - FireOnResourceReloaded(newName); return ResourceOperationResult::Ok(affected); } @@ -572,7 +571,6 @@ namespace Framework::Scripting { if (result) { const auto &a = result.GetValue(); affected.insert(affected.end(), a.begin(), a.end()); - FireOnResourceReloaded(name); } } return ResourceOperationResult::Ok(affected); @@ -772,10 +770,6 @@ namespace Framework::Scripting { _onResourceStateChanged = std::move(callback); } - void ResourceManager::SetOnResourceReloaded(ResourceEventCallback callback) { - _onResourceReloaded = std::move(callback); - } - Engine *ResourceManager::GetJSEngine() const { return _jsEngine; } @@ -1084,12 +1078,6 @@ namespace Framework::Scripting { } } - void ResourceManager::FireOnResourceReloaded(const std::string &name) { - if (_onResourceReloaded) { - _onResourceReloaded(name); - } - } - void ResourceManager::FireOnResourceError(const std::string &name, const std::string &error) { if (_onResourceError) { _onResourceError(name, error); diff --git a/code/framework/src/scripting/resource/resource_manager.h b/code/framework/src/scripting/resource/resource_manager.h index 669dffa3a..a4a75e93a 100644 --- a/code/framework/src/scripting/resource/resource_manager.h +++ b/code/framework/src/scripting/resource/resource_manager.h @@ -240,14 +240,6 @@ namespace Framework::Scripting { */ void SetOnResourceStateChanged(ResourceStateCallback callback); - /** - * Set callback for when a resource is hot-reloaded (refreshed from - * disk and restarted). Distinct from start/stop: fired only by - * RefreshResource/RefreshAll, so integrations can react to reloads - * (e.g. notify connected clients) without reacting to normal startup. - */ - void SetOnResourceReloaded(ResourceEventCallback callback); - // JS Engine Access /** @@ -378,7 +370,6 @@ namespace Framework::Scripting { // Fire resource lifecycle events void FireOnResourceStarted(const std::string &name); void FireOnResourceStopped(const std::string &name); - void FireOnResourceReloaded(const std::string &name); void FireOnResourceError(const std::string &name, const std::string &error); void FireOnResourceStateChanged(const std::string &name, ResourceState oldState, ResourceState newState); @@ -406,7 +397,6 @@ namespace Framework::Scripting { ResourceEventCallback _onResourceStopped; ResourceErrorCallback _onResourceError; ResourceStateCallback _onResourceStateChanged; - ResourceEventCallback _onResourceReloaded; // Current resource context std::string _currentResourceContext; diff --git a/docs/resource_hot_reload.md b/docs/resource_hot_reload.md index 8d4a348f6..795feec19 100644 --- a/docs/resource_hot_reload.md +++ b/docs/resource_hot_reload.md @@ -51,20 +51,24 @@ newly-added resource directories (left stopped — `start` them explicitly). ## Client propagation -When a resource with a client entry point reloads, the server notifies clients -so they re-sync and restart it in place: - -1. `RefreshResource`/`RefreshAll` fire `ResourceManager::SetOnResourceReloaded`. -2. The server `Instance` rebuilds the asset streamer's upload list - (`ClearUploads` + `InitAssetStreamer`). This is required: MafiaNet's - `DirectoryDeltaTransfer` compares the file hashes captured when files were - added, so without rebuilding, an edited file looks up-to-date and is never - re-sent. -3. The server broadcasts a `ResourceRefresh` RPC (changed resource - names/versions). +Whenever a client resource **starts at runtime** — a reload restart, an error +auto-restart, or a newly started resource — the server notifies connected +clients so they re-sync and apply it in place: + +1. Any `StartResource` after the initial boot fires + `ResourceManager::SetOnResourceStarted`; the server `Instance` reacts (boot + starts are skipped — clients get the full list on connect). +2. The server rebuilds the asset streamer's upload list (`ClearUploads` + + `InitAssetStreamer`). This is required: MafiaNet's `DirectoryDeltaTransfer` + compares the file hashes captured when files were added, so without + rebuilding, an edited file looks up-to-date and is never re-sent. +3. The server broadcasts a `ResourceRefresh` RPC (affected resource + names/versions), for resources with a client entry point. 4. The client re-runs a **targeted** delta download (only changed files transfer; unlike the connect-time download it does not stop all resources), - then calls its own `RefreshResource` for the flagged resources. + then for each flagged resource: reloads it if running, or — if the client + doesn't know it yet (newly added on the server) — discovers it from the + just-synced cache and starts it. Clients that haven't finished connecting ignore `ResourceRefresh` — their initial asset sync already fetches the current files. @@ -80,9 +84,6 @@ initial asset sync already fetches the current files. - **`require('timers')` bypasses timer tracking.** Timer cleanup wraps the global `setTimeout`/`setInterval`; code importing the `timers` module directly is not tracked. -- **Only changed existing resources propagate to clients.** A brand-new resource - added at runtime reaches already-connected clients on their next full connect, - not via `ResourceRefresh`. ## Design notes (vs FiveM / MTASA) From e0aadd402459989809339fa436cbc898fc4f5f53 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:14:21 +0200 Subject: [PATCH 10/11] Scripting: full resource lifecycle commands + stop propagation Complete the FiveM-style hot-reload surface. Commands (server Instance): - start / stop / restart / ensure , refresh (rescan only), refreshall (rescan + reload running). 'quit' shuts down the server; 'stop' with no arg still shuts down the server (back-compat), 'stop ' stops that resource. Shared helper removes the per-verb boilerplate. ResourceManager::Rescan() backs the rescan-only refresh. Client propagation: - New ResourceStop RPC: when a client resource stops at runtime (operator stop, error-stop, or the transient stop of a reload) the server broadcasts it and clients stop the resource (no file transfer). Gated, like the start broadcast, on booted && !shuttingDown. - Fix: the client now ACCUMULATES (deduped) pending refresh resources and coalesces them into a single in-flight delta download. A reload of a resource with dependents arrives as several RPCs; the previous overwrite dropped all but the last, leaving dependents stopped. --- .../src/integrations/client/instance.cpp | 44 +++++- .../src/integrations/server/instance.cpp | 144 ++++++++++++++---- .../src/integrations/server/instance.h | 4 +- .../src/networking/rpc/resource_refresh.h | 27 ++++ .../scripting/resource/resource_manager.cpp | 6 + .../src/scripting/resource/resource_manager.h | 8 + docs/resource_hot_reload.md | 26 ++-- 7 files changed, 214 insertions(+), 45 deletions(-) diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index 2398ec1ce..83008daad 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -364,9 +364,47 @@ namespace Framework::Integrations::Client { if (!sm || !sm->GetScriptingEngine() || !sm->GetScriptingEngine()->IsInitialized()) { return; } - _pendingRefreshResources = payload.resources; - Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("Server hot-reloaded {} resource(s); re-syncing", _pendingRefreshResources.size()); - SyncResourceUpdatesFromServer(); + // Accumulate (deduped) — a single reload of a resource with + // dependents arrives as several RPCs, and one delta download covers + // them all. Overwriting here 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()); + // If a re-sync is already in flight, its whole-directory delta will + // pick up these files too; OnAssetsDownloaded drains the full set. + if (!_downloadStatus.downloading) { + SyncResourceUpdatesFromServer(); + } + }); + + // Server stopped a client resource at runtime: stop it here too. + net->RegisterRPC([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. diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 634305837..177caa43f 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -184,10 +184,18 @@ namespace Framework::Integrations::Server { // resource). Gated on the initial boot: during StartAll below no client // is connected yet, and each client receives the full list on connect. _scriptingModule->GetResourceManager()->SetOnResourceStarted([this](const std::string &name) { - if (_resourcesBooted) { + if (_resourcesBooted && !_shuttingDown) { BroadcastResourceRefresh(name); } }); + // Mirror runtime stops to clients (operator stop, error-stop, or the + // transient stop of a reload — the matching start follows). Skipped + // during shutdown, when StopAll stops every resource. + _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(); @@ -483,6 +491,37 @@ namespace Framework::Integrations::Server { 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, the client just stops + // running the resource. + 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); + } + void Instance::InitCommandListener() { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Setting up command listener and processor..."); @@ -502,13 +541,37 @@ 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"); + // `stop` with no argument stops the server (back-compat); `stop ` + // stops that resource (FiveM-style). + _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 ), or the server if no resource is given"); + _commandProcessor->RegisterCommand( "status", {}, [this](cxxopts::ParseResult &) { @@ -524,65 +587,82 @@ namespace Framework::Integrations::Server { }, "Show server status"); - _commandProcessor->RegisterCommand( - "refresh", {}, - [this](cxxopts::ParseResult &result) { + // Resource lifecycle commands (FiveM-style). A small helper keeps the + // boilerplate (resolve manager, require a name, log the result) in one + // place so each verb is just its operation. + 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: refresh "); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("Usage: {} ", verb); return; } - auto res = rm->RefreshResource(args[0]); + auto res = op(rm, args[0]); if (res) { - Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed resource '{}'", args[0]); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("{}: '{}'", verb, args[0]); } else { - Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to refresh '{}': {}", args[0], res.GetError()); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to {} '{}': {}", verb, args[0], res.GetError()); } - }, - "Reload a resource from disk: refresh "); + }; + }; _commandProcessor->RegisterCommand( - "refreshall", {}, + "start", {}, + resourceCommand("start", [](Framework::Scripting::ResourceManager *rm, const std::string &n) { + return rm->StartResource(n); + }), + "Start a resource: start "); + + _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 "); + + // 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 "); + + // 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 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()); - } + auto added = rm->Rescan(); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed resources ({} new)", added.size()); }, - "Re-scan and reload all resources from disk"); + "Re-scan the resources directory for new/changed resources"); - // FiveM-style start-or-reload: reload if running, start if stopped. The - // canonical reload verb operators coming from FiveM/MTASA expect. _commandProcessor->RegisterCommand( - "ensure", {}, - [this](cxxopts::ParseResult &result) { + "refreshall", {}, + [this](cxxopts::ParseResult &) { auto *rm = _scriptingModule ? _scriptingModule->GetResourceManager() : nullptr; if (!rm) { return; } - const auto &args = result.unmatched(); - if (args.empty()) { - Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("Usage: ensure "); - return; - } - auto res = rm->IsResourceRunning(args[0]) ? rm->RefreshResource(args[0]) : rm->StartResource(args[0]); + auto res = rm->RefreshAll(); if (res) { - Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Ensured resource '{}'", args[0]); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->info("Refreshed all resources ({} affected)", res.GetValue().size()); } else { - Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to ensure '{}': {}", args[0], res.GetError()); + Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to refresh all resources: {}", res.GetError()); } }, - "Start or reload a resource: ensure "); + "Re-scan and reload all running resources from disk"); Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Command listener and processor initialized"); } diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index b670a8ff8..f70b93dd8 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -126,8 +126,10 @@ namespace Framework::Integrations::Server { void InitEndpoints(); void InitNetworkingMessages(); void InitAssetStreamer(); - // Re-sync a hot-reloaded client resource to connected clients. + // Re-sync a hot-reloaded/started client resource to connected clients. void BroadcastResourceRefresh(const std::string &name); + // Tell connected clients to stop a client resource. + void BroadcastResourceStop(const std::string &name); void InitCommandListener(); bool LoadConfigFromJSON(); void RegisterScriptingBuiltins(Framework::Scripting::Engine *); diff --git a/code/framework/src/networking/rpc/resource_refresh.h b/code/framework/src/networking/rpc/resource_refresh.h index 5d07c16be..35cc0fcae 100644 --- a/code/framework/src/networking/rpc/resource_refresh.h +++ b/code/framework/src/networking/rpc/resource_refresh.h @@ -48,4 +48,31 @@ namespace Framework::Networking::RPC { } } }; + + // Server -> client: stop these resources on the client. Sent when a client + // resource stops at runtime (operator stop, error-stop, or the transient + // stop of a reload). Carries no files — the client just stops running them. + struct ResourceStop { + static constexpr const char *kIdentifier = "Framework::ResourceStop"; + static constexpr uint16_t kMaxResources = 1000; + + std::vector resources; + + void Serialize(MafiaNet::BitStream *bs, bool write) { + uint16_t count = static_cast(std::min(resources.size(), std::numeric_limits::max())); + bs->Serialize(write, count); + if (!write) { + resources.clear(); + resources.resize(std::min(count, kMaxResources)); + } + for (uint16_t i = 0; i < count; ++i) { + if (!write && i >= kMaxResources) { + ResourceInfo discard; + discard.Serialize(bs, write); + continue; + } + resources[i].Serialize(bs, write); + } + } + }; } // namespace Framework::Networking::RPC diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index 26790574a..89d95bce1 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -576,6 +576,12 @@ namespace Framework::Scripting { return ResourceOperationResult::Ok(affected); } + std::vector ResourceManager::Rescan() { + auto added = RescanResources(); + BuildDependencyGraph(); + return added; + } + std::vector ResourceManager::RescanResources() { std::vector added; diff --git a/code/framework/src/scripting/resource/resource_manager.h b/code/framework/src/scripting/resource/resource_manager.h index a4a75e93a..539a0924f 100644 --- a/code/framework/src/scripting/resource/resource_manager.h +++ b/code/framework/src/scripting/resource/resource_manager.h @@ -168,6 +168,14 @@ namespace Framework::Scripting { */ ResourceOperationResult RefreshAll(); + /** + * Re-scan the resources directory for newly-added resources and rebuild + * the dependency graph, without starting or restarting anything (the + * FiveM-style `refresh`). New resources are left stopped. + * @return Names of resources newly discovered + */ + std::vector Rescan(); + // Registry Queries /** diff --git a/docs/resource_hot_reload.md b/docs/resource_hot_reload.md index 795feec19..1c8c72b85 100644 --- a/docs/resource_hot_reload.md +++ b/docs/resource_hot_reload.md @@ -9,13 +9,16 @@ the change. Hot-reload triggers come in two forms: -- **Console commands** (always available), registered on the server `Instance`: - - `ensure ` — start the resource if stopped, reload it if running. - The FiveM-style canonical reload verb. - - `refresh ` — reload a running resource (re-parsing its manifest); - leaves a stopped resource stopped. - - `refreshall` — re-scan the resources directory and reload everything that - was running. +- **Console commands** (always available), registered on the server `Instance` + (FiveM-style verbs): + - `start ` — start a stopped resource. + - `stop ` — stop a running resource. With **no** argument, `stop` + shuts down the server (back-compat); `quit` always shuts down the server. + - `restart ` — reload a running resource's code. + - `ensure ` — start if stopped, reload if running. The canonical + reload verb. + - `refresh` — re-scan the resources directory for new resources (no restart). + - `refreshall` — re-scan and reload everything that was running. - **Automatic file watcher** (opt-in): set `InstanceOptions.developmentMode = true` on the server. The watcher polls running resources' files and reloads any that change. It is **off by default** — leave it off in production. @@ -70,8 +73,13 @@ clients so they re-sync and apply it in place: doesn't know it yet (newly added on the server) — discovers it from the just-synced cache and starts it. -Clients that haven't finished connecting ignore `ResourceRefresh` — their -initial asset sync already fetches the current files. +Symmetrically, when a client resource **stops** at runtime (operator `stop`, +error-stop, or the transient stop within a reload), the server broadcasts a +`ResourceStop` RPC and clients stop it — no files transfer. A reload therefore +mirrors as stop-then-start on the client. + +Clients that haven't finished connecting ignore `ResourceRefresh`/`ResourceStop` +— their initial asset sync already fetches the current files. ## Limitations From d1c78542642e707c4492017c3baef521143c986c Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:25:34 +0200 Subject: [PATCH 11/11] Scripting: trim verbose comments on hot-reload code Condense the multi-line rationale comments added across the hot-reload work to terse one/two-liners; detail lives in docs/resource_hot_reload.md. No code changes. --- .../src/integrations/client/instance.cpp | 32 ++++++----------- .../src/integrations/server/instance.cpp | 27 +++++--------- .../src/integrations/server/instance.h | 3 +- .../src/networking/rpc/resource_refresh.h | 10 +++--- code/framework/src/scripting/engine.h | 19 +++------- code/framework/src/scripting/node_engine.cpp | 17 ++++----- code/framework/src/scripting/node_engine.h | 21 +++-------- .../scripting/resource/resource_manager.cpp | 35 +++++++------------ .../src/scripting/resource/resource_manager.h | 13 +++---- code/framework/src/scripting/v8_engine.cpp | 5 +-- code/framework/src/scripting/v8_engine.h | 10 ++---- .../src/scripting/v8_engine_callbacks.h | 4 +-- 12 files changed, 59 insertions(+), 137 deletions(-) diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index 83008daad..f780a81de 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -357,16 +357,14 @@ namespace Framework::Integrations::Client { if (payload.resources.empty()) { return; } - // Ignore until we've finished connecting and have a running scripting - // module. Otherwise we'd cancel the in-flight initial asset download; - // that initial sync already fetches the current (reloaded) files. + // 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) — a single reload of a resource with - // dependents arrives as several RPCs, and one delta download covers - // them all. Overwriting here would drop all but the last. + // 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) { @@ -377,8 +375,7 @@ namespace Framework::Integrations::Client { } } Logging::GetLogger(FRAMEWORK_INNER_CLIENT)->debug("Server hot-reloaded {} resource(s); re-syncing", payload.resources.size()); - // If a re-sync is already in flight, its whole-directory delta will - // pick up these files too; OnAssetsDownloaded drains the full set. + // An in-flight re-sync already covers these; it drains the full set on complete. if (!_downloadStatus.downloading) { SyncResourceUpdatesFromServer(); } @@ -549,10 +546,8 @@ namespace Framework::Integrations::Client { return; } - // Unlike DownloadsAssetsFromConnectedServer this does NOT stop all - // resources or tear down web views — only the changed resources are - // refreshed once the (delta) download completes. The asset cache path - // is already configured from the initial connect. + // 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()) { @@ -577,18 +572,14 @@ namespace Framework::Integrations::Client { auto scriptingModule = GetScriptingModule(); - // Hot-reload path: the scripting module is already running and the - // server flagged specific resources. Refresh just those (their - // files were re-synced above) instead of re-initializing the whole - // module and restarting everything. + // 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) { - // A resource the client doesn't know yet was started on - // the server at runtime: discover it from the just-synced - // cache before starting. + // Newly started server-side: discover from cache first. if (!rm->HasResource(res.name)) { const std::string resPath = GetAssetCachePath() + "/" + res.name; if (!rm->DiscoverResource(resPath)) { @@ -606,8 +597,7 @@ namespace Framework::Integrations::Client { _pendingRefreshResources.clear(); return; } - // A refresh that raced an initial connect falls through to the full - // init below, which loads the just-synced files anyway. + // A refresh that raced an initial connect falls through to full init. _pendingRefreshResources.clear(); if (scriptingModule) { diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 177caa43f..dd22fdb74 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -179,18 +179,13 @@ namespace Framework::Integrations::Server { // Initialize asset streamer (needs discovered resources to know client files) InitAssetStreamer(); - // Notify connected clients whenever a client resource starts at runtime - // (a hot-reload restart, an error auto-restart, or a newly started - // resource). Gated on the initial boot: during StartAll below no client - // is connected yet, and each client receives the full list on connect. + // 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); } }); - // Mirror runtime stops to clients (operator stop, error-stop, or the - // transient stop of a reload — the matching start follows). Skipped - // during shutdown, when StopAll stops every resource. _scriptingModule->GetResourceManager()->SetOnResourceStopped([this](const std::string &name) { if (_resourcesBooted && !_shuttingDown) { BroadcastResourceStop(name); @@ -202,8 +197,6 @@ namespace Framework::Integrations::Server { if (!startResult) { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->error("Failed to start resources: {}", startResult.GetError()); } - // Past this point, resource starts are runtime changes worth pushing to - // connected clients. _resourcesBooted = true; Logging::GetLogger(FRAMEWORK_INNER_SERVER)->flush(); @@ -476,9 +469,8 @@ namespace Framework::Integrations::Server { return; } - // Rebuild the asset streamer's upload list so the changed files get - // fresh hashes. The delta transfer compares stored hashes, so without - // this the edited file would look up-to-date and never be re-sent. + // 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(); } @@ -513,8 +505,7 @@ namespace Framework::Integrations::Server { return; } - // No streamer rebuild: stopping ships no files, the client just stops - // running the resource. + // No streamer rebuild: stopping ships no files. Framework::Networking::RPC::ResourceStop stop; stop.resources.push_back({resource->GetName(), resource->GetVersion()}); net->BroadcastRPC(stop); @@ -548,8 +539,7 @@ namespace Framework::Integrations::Server { }, "Stop the server"); - // `stop` with no argument stops the server (back-compat); `stop ` - // stops that resource (FiveM-style). + // No argument: stop the server (back-compat). With one: stop a resource. _commandProcessor->RegisterCommand( "stop", {}, [this](cxxopts::ParseResult &result) { @@ -587,9 +577,8 @@ namespace Framework::Integrations::Server { }, "Show server status"); - // Resource lifecycle commands (FiveM-style). A small helper keeps the - // boilerplate (resolve manager, require a name, log the result) in one - // place so each verb is just its operation. + // 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; diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index f70b93dd8..82c357645 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -108,8 +108,7 @@ namespace Framework::Integrations::Server { class Instance : public Framework::Lifecycle { private: std::atomic _shuttingDown; - // True once the initial StartAll has run; gates runtime resource-start - // broadcasts to clients (boot-time starts aren't pushed). + // Set after the initial StartAll; gates runtime broadcasts to clients. bool _resourcesBooted = false; std::chrono::time_point _nextTick; diff --git a/code/framework/src/networking/rpc/resource_refresh.h b/code/framework/src/networking/rpc/resource_refresh.h index 35cc0fcae..a3b795121 100644 --- a/code/framework/src/networking/rpc/resource_refresh.h +++ b/code/framework/src/networking/rpc/resource_refresh.h @@ -17,9 +17,8 @@ #include namespace Framework::Networking::RPC { - // Server -> client, dev hot-reload only: these client resources changed on - // the server and were reloaded. The client re-syncs their files from the - // asset streamer (delta transfer) and restarts them in place. + // Server -> client: these client resources were (re)started; the client + // re-syncs their files (delta) and reloads/starts them. struct ResourceRefresh { static constexpr const char *kIdentifier = "Framework::ResourceRefresh"; static constexpr uint16_t kMaxResources = 1000; // bound untrusted input @@ -49,9 +48,8 @@ namespace Framework::Networking::RPC { } }; - // Server -> client: stop these resources on the client. Sent when a client - // resource stops at runtime (operator stop, error-stop, or the transient - // stop of a reload). Carries no files — the client just stops running them. + // Server -> client: stop these resources (no files). Sent when a client + // resource stops at runtime. struct ResourceStop { static constexpr const char *kIdentifier = "Framework::ResourceStop"; static constexpr uint16_t kMaxResources = 1000; diff --git a/code/framework/src/scripting/engine.h b/code/framework/src/scripting/engine.h index 9381cffa9..a7adffc2d 100644 --- a/code/framework/src/scripting/engine.h +++ b/code/framework/src/scripting/engine.h @@ -64,23 +64,12 @@ namespace Framework::Scripting { */ virtual bool ExecuteFile(std::string_view filepath) = 0; - /** - * Evict cached modules whose resolved path sits inside the given root - * directory. Used by hot-reload so re-executing a resource's entry - * point re-reads edited files from disk instead of returning stale - * cached exports. Must be called only after the owning resource has - * been stopped. Default is a no-op for engines without a module cache. - * @param rootPath Path to a resource directory - */ + // Evict cached modules under a resource dir so hot-reload re-reads + // edited files. No-op without a module cache; call only after stop. virtual void EvictModulesUnderPath(const std::string &rootPath) {} - /** - * Cancel any timers (setTimeout/setInterval) still owned by a resource - * when it stops. In a shared runtime, re-running a resource otherwise - * leaves its old intervals firing and duplicates them on reload. - * Default is a no-op. Call only after the resource has been stopped. - * @param resourceName Name of the resource whose timers to cancel - */ + // Cancel timers the resource created (setTimeout/setInterval) so the + // shared runtime doesn't keep firing them after stop. Call after stop. virtual void ClearResourceTimers(const std::string &resourceName) {} /** diff --git a/code/framework/src/scripting/node_engine.cpp b/code/framework/src/scripting/node_engine.cpp index f9268d8e4..aaa065095 100644 --- a/code/framework/src/scripting/node_engine.cpp +++ b/code/framework/src/scripting/node_engine.cpp @@ -206,9 +206,8 @@ namespace Framework::Scripting { "globalThis.require = publicRequire;" "globalThis.Framework = {};" "globalThis.Core = {};" - // Privileged hot-reload hook: capture the real CommonJS module cache - // now, before require() is sandboxed (which hides require.cache). - // Lets C++ evict a resource's modules on reload. + // Capture the real require.cache before sandboxing hides it, so + // C++ can evict a resource's modules on reload. "Object.defineProperty(globalThis, '__fw_evictModulesUnderPath', {" " value: function(root, ci) {" " try {" @@ -223,12 +222,9 @@ namespace Framework::Scripting { " } catch (e) { return 0; }" " }, writable: false, configurable: false, enumerable: false" "});" - // Per-resource timer tracking: wrap the global timer functions so a - // resource's setTimeout/setInterval can be cancelled when it stops - // (a shared runtime would otherwise keep firing them across reloads). - // Timers are attributed to a resource via __fw_ownerOf (installed - // from C++ after Init). The real timer handle is returned unchanged, - // so user clearTimeout/unref and util.promisify keep working. + // Wrap global timers to track them per resource (attributed via + // __fw_ownerOf) so a resource's timers can be cancelled on stop. + // The real handle is returned, so clearTimeout/unref/promisify work. "(function(){" " const reg = new Map();" " const _st = globalThis.setTimeout, _si = globalThis.setInterval," @@ -394,8 +390,7 @@ namespace Framework::Scripting { if (rootStr.empty()) { return; } - // Trailing slash enforces a directory boundary in the JS startsWith - // check so /res/a doesn't evict modules of /res/ab. + // Trailing slash so /res/a doesn't match /res/ab in the JS prefix check. if (rootStr.back() != '/') { rootStr += '/'; } diff --git a/code/framework/src/scripting/node_engine.h b/code/framework/src/scripting/node_engine.h index d28ef32d2..c28de795e 100644 --- a/code/framework/src/scripting/node_engine.h +++ b/code/framework/src/scripting/node_engine.h @@ -86,24 +86,14 @@ namespace Framework::Scripting { void Shutdown() override; bool ExecuteFile(std::string_view filepath) override; - /** - * Evict CommonJS modules cached under rootPath so a subsequent - * require() of the resource entry point re-reads edited files. Used by - * hot-reload; call only after the owning resource has been stopped. - */ + // Evict CommonJS modules cached under rootPath (require.cache). void EvictModulesUnderPath(const std::string &rootPath) override; - /** - * Cancel timers created by the named resource (via the bootstrap timer - * tracking shim). Call after the resource has stopped. - */ + // Cancel timers the named resource created (via the bootstrap shim). void ClearResourceTimers(const std::string &resourceName) override; - /** - * Install the privileged __fw_ownerOf(fn) helper used by the bootstrap - * timer-tracking shim to attribute timers to resources. Must be called - * once after Init(), with V8 scopes active. - */ + // Install the privileged __fw_ownerOf(fn) helper for the timer shim. + // Call once after Init() with V8 scopes active. void InstallResourceTimerTracking(); /** @@ -177,8 +167,7 @@ namespace Framework::Scripting { static void OnUncaughtError(const v8::FunctionCallbackInfo &info); - // Privileged JS helper: returns the resource name owning a function, - // resolved from its script origin. Backs the timer-tracking shim. + // __fw_ownerOf(fn): resource owning a function, from its script origin. static void OnTimerOwnerLookup(const v8::FunctionCallbackInfo &info); NodeEngineOptions _options; diff --git a/code/framework/src/scripting/resource/resource_manager.cpp b/code/framework/src/scripting/resource/resource_manager.cpp index 89d95bce1..f80954984 100644 --- a/code/framework/src/scripting/resource/resource_manager.cpp +++ b/code/framework/src/scripting/resource/resource_manager.cpp @@ -42,9 +42,8 @@ namespace Framework::Scripting { return true; } - // Newest last-write-time across all files under a resource directory, - // as implementation-defined clock ticks (monotonic for comparison on a - // given platform). Returns 0 if the directory can't be scanned. + // Newest file mtime under a resource directory, in impl-defined clock + // ticks (monotonic for comparison). 0 if the directory can't be scanned. int64_t ComputeResourceMTime(const std::string &path) { std::error_code ec; int64_t newest = 0; @@ -58,8 +57,7 @@ namespace Framework::Scripting { if (ec) { break; } - // Don't descend into dependency/VCS trees — they're large and - // not hand-edited, so polling them every tick is wasted work. + // Skip large, non-hand-edited trees. if (it->is_directory(ec) && !ec) { const auto dirName = it->path().filename().string(); if (dirName == "node_modules" || dirName == ".git") { @@ -406,8 +404,7 @@ namespace Framework::Scripting { // Call cleanup (removes handlers) CallResourceStop(name); - // Cancel timers the resource left running (raw setTimeout/setInterval), - // which the shared runtime would otherwise keep firing across reloads. + // Cancel timers the resource left running. if (_jsEngine) { _jsEngine->ClearResourceTimers(std::string(name)); } @@ -426,12 +423,9 @@ namespace Framework::Scripting { } ResourceOperationResult ResourceManager::RestartResource(std::string_view name) { - // Capture the resource root so we can evict its cached modules between - // stop and start. In a shared runtime a plain stop+start re-executes the - // entry against stale require()/module caches and does NOT pick up code - // changes; eviction makes "restart" actually reload code, matching - // per-resource-runtime engines (FiveM/MTASA) where restart inherently - // reloads. This also gives error auto-restart a clean module slate. + // Evict the resource's modules between stop and start: in a shared + // runtime a plain stop+start would re-run against stale caches and not + // reload code. See docs/resource_hot_reload.md. std::string resourceRoot; { std::scoped_lock lock(_resourcesMutex); @@ -446,7 +440,7 @@ namespace Framework::Scripting { return stopResult; } - // Evict only after the resource is stopped (engine cache contract). + // Evict only after stop (engine cache contract). if (_jsEngine && !resourceRoot.empty()) { _jsEngine->EvictModulesUnderPath(resourceRoot); } @@ -455,8 +449,6 @@ namespace Framework::Scripting { } ResourceOperationResult ResourceManager::ReloadResource(std::string_view name) { - // RestartResource now evicts modules, so reload is just a restart that - // re-reads the resource's code from disk. return RestartResource(name); } @@ -473,9 +465,8 @@ namespace Framework::Scripting { wasRunning = it->second->IsRunning(); } - // Stop first (cascades to dependents) so we can replace the registry - // entry and evict its modules safely. Remember everything that went - // down so we can bring it all back up afterwards. + // Stop first (cascades to dependents); remember what went down to + // restart it all afterwards. std::vector toRestart; if (wasRunning) { auto stopResult = StopResource(name); @@ -509,8 +500,7 @@ namespace Framework::Scripting { return ResourceOperationResult::Ok({newName}); } - // Restart the resource itself plus any dependents that cascaded down. - // StartResource pulls dependencies in automatically, so order is safe. + // Restart the resource plus the dependents that cascaded down. std::vector affected; for (const auto &stoppedName : toRestart) { const std::string startName = (stoppedName == oldName) ? newName : stoppedName; @@ -1064,8 +1054,7 @@ namespace Framework::Scripting { if (current > snapIt->second) { Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) ->info("Detected change in resource '{}', hot-reloading", name); - // Update before reloading so a reload-induced rescan next tick - // doesn't re-trigger on the same change. + // Update before reloading so we don't re-trigger on it. snapIt->second = current; RefreshResource(name); } diff --git a/code/framework/src/scripting/resource/resource_manager.h b/code/framework/src/scripting/resource/resource_manager.h index 539a0924f..7bb76812c 100644 --- a/code/framework/src/scripting/resource/resource_manager.h +++ b/code/framework/src/scripting/resource/resource_manager.h @@ -48,8 +48,7 @@ namespace Framework::Scripting { // Whether to warn (instead of error) when a dependency is missing bool warnOnMissingDependency = false; - // Development mode: poll resource files for changes and auto-reload. - // Should stay off in production (auto-reload is a dev convenience). + // Dev mode: poll resource files and auto-reload on change (off in prod). bool devMode = false; // Minimum interval between file-change polls, in milliseconds. @@ -348,11 +347,8 @@ namespace Framework::Scripting { */ void ProcessScheduledRestarts(); - /** - * In dev mode, poll running resources' files for changes (throttled by - * fileWatchIntervalMs) and hot-reload any that changed on disk. No-op - * when devMode is disabled. Call from the scripting update tick. - */ + // Dev mode: poll running resources for file changes and hot-reload + // them. No-op unless devMode. Call from the scripting update tick. void ProcessFileWatch(); private: @@ -418,8 +414,7 @@ namespace Framework::Scripting { std::vector _scheduledRestarts; mutable std::mutex _scheduledRestartsMutex; - // Dev-mode file watcher state (accessed only from the update thread). - // Maps resource name -> newest file mtime seen (impl-defined ticks). + // Dev-mode file watcher: resource name -> newest file mtime seen. std::map _watchSnapshots; std::chrono::steady_clock::time_point _lastFileWatchPoll{}; diff --git a/code/framework/src/scripting/v8_engine.cpp b/code/framework/src/scripting/v8_engine.cpp index 06f97be76..af88daf8d 100644 --- a/code/framework/src/scripting/v8_engine.cpp +++ b/code/framework/src/scripting/v8_engine.cpp @@ -70,10 +70,7 @@ namespace Framework::Scripting { return; } - // Disposing v8::Global handles touches the isolate; since the engine - // runs in locking mode (Engine::Execute takes a Locker), hold one here - // too. No HandleScope: Global::Reset needs none, and we create no - // Local handles. Locker is reentrant, so a locked caller is fine. + // Hold a Locker to dispose Global handles (engine runs in locking mode). v8::Locker locker(_isolate); v8::Isolate::Scope isolateScope(_isolate); diff --git a/code/framework/src/scripting/v8_engine.h b/code/framework/src/scripting/v8_engine.h index a7328eb6f..9de450753 100644 --- a/code/framework/src/scripting/v8_engine.h +++ b/code/framework/src/scripting/v8_engine.h @@ -84,16 +84,10 @@ namespace Framework::Scripting { */ void ClearModuleCache(); - /** - * Evict cached modules (and their require() backing data) whose - * resolved path sits inside rootPath. Used by hot-reload; call only - * after the owning resource has been stopped. - */ + // Evict cached modules (and require() backing data) under rootPath. void EvictModulesUnderPath(const std::string &rootPath) override; - /** - * Cancel all timers created by the named resource. Call after stop. - */ + // Cancel all timers created by the named resource. void ClearResourceTimers(const std::string &resourceName) override; // Module loader internals (used by require callback) diff --git a/code/framework/src/scripting/v8_engine_callbacks.h b/code/framework/src/scripting/v8_engine_callbacks.h index e95d218e9..7c7fec6d0 100644 --- a/code/framework/src/scripting/v8_engine_callbacks.h +++ b/code/framework/src/scripting/v8_engine_callbacks.h @@ -147,9 +147,7 @@ namespace Framework::Scripting::V8EngineCallbacks { entry->intervalMs = data->isInterval ? (delayMs > 0 ? delayMs : 1) : 0; entry->cancelled = false; - // Attribute the timer to its owning resource (for cleanup on stop), - // mirroring how the event system attributes async handlers: prefer the - // callback's script origin, then the current context, then the stack. + // Owner for cleanup on stop: callback script origin, then context, then stack. if (auto *mgr = data->engine->GetResourceManager()) { std::string owner = mgr->GetResourceNameFromFunction(isolate, callbackFn); if (owner.empty()) {