From 9b75dc5fd2fb4eb702e1a8c5f3e9bcb593edf7a6 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Tue, 7 Jul 2026 11:09:44 -0600 Subject: [PATCH] Service lifecycle events --- docs/modding.md | 20 +++++++++ include/dusk/mod_loader.hpp | 4 +- include/mods/svc/host.h | 35 +++++++++++++++- src/dusk/mods/loader/loader.cpp | 10 +++++ src/dusk/mods/svc/host.cpp | 73 +++++++++++++++++++++++++++++++-- src/dusk/mods/svc/log.cpp | 9 ++-- src/dusk/mods/svc/registry.cpp | 69 +++++++++++++++++++++++++++++-- src/dusk/mods/svc/registry.hpp | 36 +++++++++++++++- 8 files changed, 241 insertions(+), 15 deletions(-) diff --git a/docs/modding.md b/docs/modding.md index 12c7881ade..f3332da0e9 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -156,6 +156,26 @@ svc_host->fail(mod_ctx, MOD_ERROR, "something unrecoverable happened"); // disa `get_service`/`publish_service` provide dynamic service lookup; see [Advanced](#advanced-exporting-services). +**Lifecycle watches.** If your mod provides a service that hands out per-caller state (registrations, callbacks, handles), +watch other mods' lifecycle and drop what you hold for a mod when it detaches. + +```cpp +IMPORT_SERVICE_VERSION(HostService, svc_host, 1); + +void on_mod_lifecycle(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data) { + if (event == MOD_LIFECYCLE_DETACHED) { + drop_state_for(subject); // same ModContext* the subject passed into your service + } +} + +uint64_t watch = 0; +svc_host->watch_mod_lifecycle(mod_ctx, on_mod_lifecycle, nullptr, &watch); +``` + +`MOD_LIFECYCLE_DETACHED` fires on the game thread at a lifecycle safe point, after the subject's `mod_shutdown` ran and +every service dropped its state. For your own mod's teardown, use `mod_shutdown` instead. + --- ## Runtime Lifecycle diff --git a/include/dusk/mod_loader.hpp b/include/dusk/mod_loader.hpp index 2a67e209f7..20b4de8e70 100644 --- a/include/dusk/mod_loader.hpp +++ b/include/dusk/mod_loader.hpp @@ -216,8 +216,8 @@ private: // Registers exports (if needed), resolves imports and runs mod_initialize. // Returns whether the mod ended up active; failures go through fail_mod. bool activate_mod(LoadedMod& mod); - // Runs mod_shutdown (if needed), removes the mod's services, and unloads the native lib. - // Must only run with no mod code on the stack (startup, shutdown, or top of tick). + // Runs mod_shutdown (if needed), detaches the mod from every service, and unloads the + // native lib. Must only run with no mod code on the stack (startup, shutdown, or top of tick). void deactivate_mod(LoadedMod& mod); void init_services(); bool register_static_service_exports(LoadedMod& mod); diff --git a/include/mods/svc/host.h b/include/mods/svc/host.h index 6f5844a88f..61d0831dae 100644 --- a/include/mods/svc/host.h +++ b/include/mods/svc/host.h @@ -9,7 +9,28 @@ #define HOST_SERVICE_ID "dev.twilitrealm.dusklight.host" #define HOST_SERVICE_MAJOR 1u -#define HOST_SERVICE_MINOR 0u +#define HOST_SERVICE_MINOR 1u + +/* + * Ignore unknown values: later service minors may add events. + */ +typedef enum ModLifecycleEvent { + /* + * The subject mod is gone: its mod_shutdown has run (when it initialized at all) and + * every service has already dropped the state it held for it. The subject's library is + * still mapped, so pointers into it are valid to compare against, but they must not be + * called or dereferenced after the callback returns. Drop everything keyed to the + * mod: callbacks it registered, its ModContext*, state indexed by it. + */ + MOD_LIFECYCLE_DETACHED = 0, +} ModLifecycleEvent; + +/* + * ctx is the watching mod's own context; subject identifies the mod the event is about. + * subject_id is valid only for the duration of the call. + */ +typedef void (*ModLifecycleFn)(ModContext* ctx, ModContext* subject, const char* subject_id, + ModLifecycleEvent event, void* user_data); typedef struct HostService { ServiceHeader header; @@ -50,6 +71,18 @@ typedef struct HostService { * and reload within a session, but the directory is wiped at game startup. */ const char* (*mod_dir)(ModContext* ctx); + + /* + * Observe other mods' lifecycle events. Any mod whose service hands out per-caller state + * (registrations, callbacks, handles) should watch for MOD_LIFECYCLE_DETACHED and drop what it + * holds for the subject. + * + * Callbacks fire on the game thread at a lifecycle safe point (never mid-frame), for + * every mod but the watcher itself (use mod_shutdown for self-cleanup). + */ + ModResult (*watch_mod_lifecycle)( + ModContext* ctx, ModLifecycleFn fn, void* user_data, uint64_t* out_handle); + ModResult (*unwatch_mod_lifecycle)(ModContext* ctx, uint64_t handle); } HostService; #ifdef __cplusplus diff --git a/src/dusk/mods/loader/loader.cpp b/src/dusk/mods/loader/loader.cpp index 769ec28e87..a8459717e6 100644 --- a/src/dusk/mods/loader/loader.cpp +++ b/src/dusk/mods/loader/loader.cpp @@ -599,6 +599,8 @@ bool ModLoader::activate_mod(LoadedMod& mod) { warn_unpublished_deferred_exports(mod); if (!mod.active) { + // Failed initialization may have left hooks or other service state behind + svc::modules_mod_detached(mod); return false; } @@ -624,6 +626,7 @@ void ModLoader::deactivate_mod(LoadedMod& mod) { svc::remove_services_for_provider(mod); mod.servicesRegistered = false; } + svc::modules_mod_detached(mod); unload_native(mod); mod.active = false; @@ -739,6 +742,8 @@ void ModLoader::init() { } } + svc::modules_lifecycle_applied(); + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); Log.info("{}/{} mod(s) active", active, m_mods.size()); @@ -1040,11 +1045,14 @@ void ModLoader::apply_pending_requests() { apply_lifecycle_change(*mod, request.kind == RequestKind::Reload); } + svc::modules_lifecycle_applied(); + auto active = std::ranges::count_if(mods(), [](const LoadedMod& m) { return m.active; }); Log.info("{}/{} mod(s) active", active, m_mods.size()); } void ModLoader::tick() { + svc::modules_frame_begin(); apply_pending_requests(); for (auto& mod : mods()) { @@ -1064,6 +1072,7 @@ void ModLoader::tick() { } } + svc::modules_frame_end(); config_flush_if_dirty(false); } @@ -1080,6 +1089,7 @@ void ModLoader::shutdown() { m_mods.clear(); drain_retired_natives(); + svc::modules_shutdown(); clear_services(); config_flush_if_dirty(true); Log.info("all mods unloaded"); diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index f107aa0b84..6255b13c42 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,6 +1,10 @@ #include "registry.hpp" #include "dusk/mods/loader/loader.hpp" +#include "fmt/format.h" + +#include +#include namespace dusk::mods::svc { namespace { @@ -56,6 +60,63 @@ const char* host_mod_dir(ModContext* context) { return mod != nullptr ? mod->dir.c_str() : ""; } +struct LifecycleWatcher { + uint64_t handle = 0; + LoadedMod* owner = nullptr; + ModLifecycleFn fn = nullptr; + void* userData = nullptr; +}; + +std::vector s_watchers; +uint64_t s_nextWatchHandle = 1; + +ModResult host_watch_mod_lifecycle( + ModContext* context, ModLifecycleFn fn, void* userData, uint64_t* outHandle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || fn == nullptr || outHandle == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto handle = s_nextWatchHandle++; + s_watchers.push_back({handle, mod, fn, userData}); + *outHandle = handle; + return MOD_OK; +} + +ModResult host_unwatch_mod_lifecycle(ModContext* context, const uint64_t handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + const auto erased = std::erase_if(s_watchers, + [&](const LifecycleWatcher& w) { return w.handle == handle && w.owner == mod; }); + return erased != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; +} + +void host_mod_detached(LoadedMod& mod) { + // The subject's own watches go first: a mod is never notified about its own teardown. + std::erase_if(s_watchers, [&](const LifecycleWatcher& w) { return w.owner == &mod; }); + + // Iterate a snapshot: callbacks may watch/unwatch, and a failing callback erases the + // failing mod's services. + const auto snapshot = s_watchers; + for (const auto& watcher : snapshot) { + const bool alive = std::ranges::any_of( + s_watchers, [&](const LifecycleWatcher& w) { return w.handle == watcher.handle; }); + if (!alive) { + continue; + } + try { + watcher.fn(watcher.owner->context.get(), mod.context.get(), mod.metadata.id.c_str(), + MOD_LIFECYCLE_DETACHED, watcher.userData); + } catch (const std::exception& e) { + fail_mod(*watcher.owner, MOD_ERROR, + fmt::format("exception in mod lifecycle callback: {}", e.what())); + } catch (...) { + fail_mod(*watcher.owner, MOD_ERROR, "unknown exception in mod lifecycle callback"); + } + } +} + constexpr HostService s_hostService{ .header = SERVICE_HEADER(HostService, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR), .get_service = host_get_service, @@ -65,12 +126,18 @@ constexpr HostService s_hostService{ .mod_name = host_mod_name, .mod_version = host_mod_version, .mod_dir = host_mod_dir, + .watch_mod_lifecycle = host_watch_mod_lifecycle, + .unwatch_mod_lifecycle = host_unwatch_mod_lifecycle, }; } // namespace -const HostService& host_service() { - return s_hostService; -} +constinit const ServiceModule g_hostModule{ + .id = HOST_SERVICE_ID, + .majorVersion = HOST_SERVICE_MAJOR, + .minorVersion = HOST_SERVICE_MINOR, + .service = &s_hostService, + .modDetached = host_mod_detached, +}; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/log.cpp b/src/dusk/mods/svc/log.cpp index 5d10d8cac2..d9fc88eae7 100644 --- a/src/dusk/mods/svc/log.cpp +++ b/src/dusk/mods/svc/log.cpp @@ -57,8 +57,11 @@ constexpr LogService s_logService{ } // namespace -const LogService& log_service() { - return s_logService; -} +constinit const ServiceModule g_logModule{ + .id = LOG_SERVICE_ID, + .majorVersion = LOG_SERVICE_MAJOR, + .minorVersion = LOG_SERVICE_MINOR, + .service = &s_logService, +}; } // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 9e61d0791f..3951822bad 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -3,13 +3,16 @@ #include "dusk/logging.h" #include "dusk/mods/loader/loader.hpp" +#include #include #include +#include namespace dusk::mods::svc { namespace { std::unordered_map s_services; +std::vector s_modules; std::string service_key(std::string_view id, const uint16_t majorVersion) { std::string key{id}; @@ -130,6 +133,60 @@ const ServiceRecord* find_service_record(const char* serviceId, const uint16_t m void clear_services() { s_services.clear(); + s_modules.clear(); +} + +ModResult register_module(const ServiceModule& module) { + const auto result = register_service( + module.id, module.majorVersion, module.minorVersion, module.service, nullptr, false); + if (result != MOD_OK) { + return result; + } + s_modules.push_back(&module); + if (module.initialize != nullptr) { + module.initialize(); + } + return MOD_OK; +} + +void modules_mod_detached(LoadedMod& mod) { + for (const auto* module : s_modules | std::views::reverse) { + if (module->modDetached != nullptr) { + module->modDetached(mod); + } + } +} + +void modules_lifecycle_applied() { + for (const auto* module : s_modules) { + if (module->lifecycleApplied != nullptr) { + module->lifecycleApplied(); + } + } +} + +void modules_frame_begin() { + for (const auto* module : s_modules) { + if (module->frameBegin != nullptr) { + module->frameBegin(); + } + } +} + +void modules_frame_end() { + for (const auto* module : s_modules) { + if (module->frameEnd != nullptr) { + module->frameEnd(); + } + } +} + +void modules_shutdown() { + for (const auto* module : s_modules | std::views::reverse) { + if (module->shutdown != nullptr) { + module->shutdown(); + } + } } } // namespace dusk::mods::svc @@ -138,10 +195,14 @@ namespace dusk::mods { void ModLoader::init_services() { svc::clear_services(); - svc::register_service(HOST_SERVICE_ID, HOST_SERVICE_MAJOR, HOST_SERVICE_MINOR, - &svc::host_service(), nullptr, false); - svc::register_service( - LOG_SERVICE_ID, LOG_SERVICE_MAJOR, LOG_SERVICE_MINOR, &svc::log_service(), nullptr, false); + for (const auto* module : + { + &svc::g_hostModule, + &svc::g_logModule, + }) + { + svc::register_module(*module); + } } bool ModLoader::register_static_service_exports(LoadedMod& mod) { diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 0b850840d9..4f59aabff1 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -18,6 +18,31 @@ struct ServiceRecord { bool deferred = false; }; +// A host service and its lifecycle hooks. Every hook is optional. Frame and lifecycle hooks run in +// registration order, modDetached in reverse registration order. +struct ServiceModule { + const char* id = nullptr; + uint16_t majorVersion = 0; + uint16_t minorVersion = 0; + const void* service = nullptr; + + // One-time setup, at registration (ModLoader::init_services). + void (*initialize)() = nullptr; + // A mod is going away (deactivation or failed activation): drop all state held for it. + // Runs after the mod's mod_shutdown and before its library unloads, so pointers into + // the mod are still valid but must not be called. + void (*modDetached)(LoadedMod& mod) = nullptr; + // A batch of (de)activations finished applying: startup, and runtime enable/disable/ + // reload requests. The set of active mods is stable when this runs. + void (*lifecycleApplied)() = nullptr; + // Top of ModLoader::tick, before pending lifecycle requests apply. + void (*frameBegin)() = nullptr; + // End of ModLoader::tick, after every mod_update. + void (*frameEnd)() = nullptr; + // ModLoader::shutdown, after every mod has deactivated. + void (*shutdown)() = nullptr; +}; + bool valid_service_id(const char* serviceId); ModResult register_service(const char* serviceId, uint16_t majorVersion, uint16_t minorVersion, const void* service, LoadedMod* provider, bool deferred); @@ -30,7 +55,14 @@ const ServiceRecord* find_service( const ServiceRecord* find_service_record(const char* serviceId, uint16_t majorVersion); void clear_services(); -const HostService& host_service(); -const LogService& log_service(); +ModResult register_module(const ServiceModule& module); +void modules_mod_detached(LoadedMod& mod); +void modules_lifecycle_applied(); +void modules_frame_begin(); +void modules_frame_end(); +void modules_shutdown(); + +extern const ServiceModule g_hostModule; +extern const ServiceModule g_logModule; } // namespace dusk::mods::svc