CityRP Documentation Repository

Is `GM:Initialize` the right world-load boot predicate for `cityrp.session`?

Last updated

Research for limelight-development/cityrp2#3432 — "Confirm GM:Initialize is the right world-load boot predicate for cityrp.session".

Verdict

GM:Initialize is a correct world-load predicate, but it is the wrong mechanism for a core library to hang a session token on — and cityrp.session does not need it at all.

The premise in the issue ("a changelevel keeps the process and Lua state alive while wiping every entity") is contradicted by the wiki: GM:ShutDown is documented as "Called whenever the Lua environment is about to be shut down, for example on map change". The Lua state is torn down and rebuilt on every changelevel/map. World-load lifetime and Lua-state lifetime are therefore the same lifetime, and every file in gamemode/core/libraries/ is executed exactly once per world load by construction.

So the recommended predicate is the load of the library file itself, guarded so a manual lua_refresh_file re-execution does not re-mint the token — the sh_tophints.lua:14/:27-33 idiom, not a hook:

cityrp.session = cityrp.session or {}
if not cityrp.session.token then cityrp.session.token = ... end

This is strictly better than hook.Add("Initialize", ...), which is exposed to AGENTS.md footgun #3 (any earlier hook.Add("Initialize") returning non-nil short-circuits the rest of the chain, and 30-odd addons register on that event — see §2).

processId should be dropped as a stored identity. Nothing in Lua survives the state teardown, so a process id cannot be derived — only persisted and re-read. If a consumer genuinely needs "is this the same OS process", expose the engine primitive game.GetMapChangeCount() directly (== 1 means "first world load of this process") rather than minting a second token.

There is also a live bug this maps onto: refund_ents/sv_init.lua:21 already implements this exact pattern without the guard.


1. Does GM:Initialize fire exactly once per map load, and what happens on changelevel / map / gamemode reload?

Wiki (engine semantics). GM:Initialize is called "after the gamemode loads and starts", before any entities exist — a one-time gamemode initialization hook. GM:InitPostEntity is "called after all the entities are initialized", once per map load, on both realms. GM:ShutDown is "called whenever the Lua environment is about to be shut down, for example on map change, or when the server is going to shut down."

That ShutDown wording is the load-bearing fact. On a changelevel the Lua environment is shut down, not preserved. game.GetMapLoadType() returning "newgame" after a changelevel (game library) is consistent with that. So the sequence for a map change is: GM:ShutDown → Lua state destroyed → new Lua state → gamemode files re-executed → GM:Initialize → GM:InitPostEntity.

This repo. The gamemode defines each of these exactly once:

  • gamemodes/cityrp_2_by_limelightgaming/gamemode/init.lua:173 — function GM:Initialize()
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/init.lua:624 — function GM:InitPostEntity()
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/init.lua:2395 — function GM:ShutDown()

GM:Initialize (init.lua:173-181) does three things: self:ConnectToDatabase(), clears cityrp.disablesave, and chains self.BaseClass:Initialize(). GM:ConnectToDatabase (init.lua:109-111) constructs a new DB_MAIN pool (DB_MAIN = DB:New(cityrpserver)) and runs pending migrations. A second entry into GM:Initialize in the same Lua state would therefore silently orphan the previous connection pool and re-run the migration gate — strong in-repo evidence that this path is designed and relied on to fire exactly once.

GM:ShutDown (init.lua:2395-2402) saves every player and flags them offline in MySQL, i.e. it is already treated here as the end of a world session.

Grep result — nothing re-triggers it. Across the whole repository (gamemode, addons/, lua/) there is no hook.Run("Initialize"), hook.Call("Initialize"), GAMEMODE:Initialize() or any other manual invocation. The only occurrence of the symbol at all is the definition at init.lua:173.

2. Can the plugin loader or hot-reload machinery re-enter it?

No, and there is no in-repo reload machinery to speak of.

  • gamemode/core/libraries/sh_plugin.lua registers zero console commands and zero cityrp.command.add / cityrp.acommands.AddCommand entries. Its public surface is Discover/Resolve/SeedGlobal/AutoRegister/ClearGlobal/register/call/get/ IsAvailable/GetDependencyStatus/LoadSubResource*. None of them include init.lua or invoke a gamemode method.
  • The loader runs once, inline, at gamemode/sh_init.lua:131-133 (cityrp.plugin.Resolve() then the for ... in ipairs(plugins) include loop). It is a file-scope loop, not a callable entry point — there is no cityrp.plugin.ReloadAll.
  • The only lua_reload-alike concommands anywhere in the tree are in third-party addons and all are addon-local: AdvDupe_Restart_Think (addons/advduplicator/lua/autorun/server/AdvDupe.lua:1811), cl_refreshlegs (addons/gmod_legs_3_112806637/lua/autorun/sh_legs.lua:418), rradio_reload_permanent_boomboxes (addons/rradio_3318060741/lua/rradio/server/sv_permanent.lua:336), precision_align…reloadui (addons/stool_precisionalign/…/precision_align.lua:225), request_reloadmapwarpareas (addons/warp/lua/weapons/gmod_tool/stools/warpdesttool.lua:359), custom_spawnlist_reloadvgui (lua/csl/custom_spawnlist.lua:34). None re-include the gamemode or call Initialize.
  • ~30 addons register hook.Add("Initialize", ...) / hook.Add("InitPostEntity", ...) (damagelog, prone_mod, rradio, 3dsign, smartsnap, cw20, stove, pcasino, sit_anywhere, advdupe2, …). These listen; none of them fire the event. They matter only for footgun #3 ordering — which is the reason to prefer file-scope derivation over a hook (see the Verdict).
  • The only RunString in the gamemode is the developer console in gamemode/core/libraries/cl_temar.lua:22,68 — clientside, arbitrary user code, not a gamemode reloader.

Auto refresh. The wiki's Auto Refresh/HotLoading page confirms the manual entry point is lua_refresh_file <path> (not lua_reload), that it can be disabled server-wide with the -disableluarefresh startup parameter, and — critically — that "nothing is unloaded — only re-executed". A refresh therefore re-executes a file body. It does not call hooks. Re-executing init.lua would redefine GM:Initialize without invoking it.

Conclusion for the ticket's asked-for confirmation: nothing in this repo's reload/refresh tooling re-triggers GM:Initialize without an actual world load. The residual risk is not re-entry — it is file-body re-execution, which is a different failure and the one the guard addresses.

3. Does a sv_session.lua loaded from includeCore("libraries") survive a reload of itself?

Not without a guard. It would silently re-mint a new token.

includeCore is defined at gamemode/sh_init.lua:68-88 and invoked at :108-109. It file.Finds core/<path>/*.lua, AddCSLuaFiles the sh_/cl_ ones and includes the sh_/sv_ ones on the server. A core/libraries/sv_session.lua is thus a plain include() of a file body — exactly the shape auto refresh / lua_refresh_file re-executes. Any local token = os.time() or cityrp.session = { token = ... } written at file scope is re-derived on refresh, and every consumer comparing against a persisted token starts silently disagreeing with the database.

The established idiom. gamemode/core/libraries/sh_tophints.lua is the clean prior art at library level:

  • :14 — cityrp.topHints = cityrp.topHints or {}
  • :27-29 — cityrp.topHints.cycleSeconds = cityrp.topHints.cycleSeconds or 15, .prefixes = ... or {}, .hints = ... or {}
  • :30-33 — and the sharp edge already documented in-repo: -- Preserve an explicit false across hot reload ("x or true" would always force true). followed by an if cityrp.topHints.shuffleHints == nil then guard.

carboosting/sv_init.lua:544 is the plugin-level cousin, and its comment states the mechanism explicitly: "A hot-reload of this file runs local PLUGIN = {} again (see the top of this file), which loses track of any fence NPC spawned by the previous instance" — with local PLUGIN = {} at carboosting/sv_init.lua:6. Its if not self.FilingNPCsByKey then guard is the same shape as the if PLUGIN.token then return end the ticket describes.

The live counter-example — an existing bug. refund_ents already implements a session token, unguarded:

-- gamemode/core/plugins/refund_ents/sv_init.lua:18-23
local PLUGIN = PLUGIN
PLUGIN.name = "Entity Refund System"
PLUGIN.author = "Faustie"
PLUGIN.session = os.time()
PLUGIN.lastSession = 0
PLUGIN.server = LLServer

PLUGIN.session is written to the database (:96, :126, :157, :170, :238) and read back as the "is this entity from the current world" test at :179:

local current = row.start_time == self.session

A lua_refresh_file of that one file re-runs PLUGIN.session = os.time(), after which every still-standing entity in the world reads as not-current and refund/ownership restoration is wrong for the rest of the map. (This file also does local PLUGIN = PLUGIN — and the loader nils the global in cityrp.plugin.ClearGlobal, sh_plugin.lua:174-181 — so a bare refresh would in fact error on PLUGIN.name before reaching line 21. Either way the file does not survive its own reload; the guard is what makes it survive.) Flagging this as a candidate follow-up issue rather than fixing it here, since #3432 is read-only.

One residual hazard. gamemode/sh_init.lua:11 does cityrp = {} at file scope. A lua_refresh_file of sh_init.lua itself wipes the entire cityrp namespace and re-runs the whole includeCore/plugin pass, so it would mint a new token. But that operation destroys every library and plugin table on the server simultaneously; it is not a session-specific risk and no guard in sv_session.lua can defend against it.

4. processId: keep or drop?

Drop the stored processId. Expose game.GetMapChangeCount() if a consumer actually needs it.

The charting assumed the Lua state outlives a changelevel, which would make process lifetime a distinct, observable thing. Per §1 it does not. Consequences:

  • No Lua value can be a process id. SysTime is "a highly accurate time in seconds since the start up" and CurTime is "the uptime of the server in seconds" — but both are read from a fresh Lua state after a map change, and neither the wiki nor anything I could test establishes what they read on the second map of a process. Unverified either way, and moot: even if one of them kept counting, a monotonic clock is not an identity. os.time is wall-clock seconds since the epoch — it gives you when this world loaded, not which process.
  • The one genuine engine primitive is game.GetMapChangeCount(). The wiki describes it as "the current map change count for the server… useful to determine whether the current map is the initial map, or whether a changelevel (using map command is also detected) has occurred at any point in the server's session", and "will start at 1". It is engine-side, so it survives the Lua teardown that nothing in Lua does. == 1 means "first world load of this process".
  • It is an ordinal, not an identity. To turn it into a stable processId you would have to mint one when game.GetMapChangeCount() == 1 and persist it to disk (the way carboosting persists its filing point with file.Write, carboosting/sv_init.lua:535), re-reading it on every subsequent world load. That is possible and cheap, but it is storage, not a derived value, and it buys nothing for refund_ents — whose whole question is "were these entities destroyed", which world-load identity already answers exactly.

Recommendation: ship cityrp.session.token (world-load) only. If a later consumer needs process scope, add a thin cityrp.session.IsFirstMapOfProcess() wrapping game.GetMapChangeCount() == 1 rather than a second minted token.

Derive at file scope with an idempotence guard — no hook, so no exposure to footgun #3.

--[[-- Server session identity (`cityrp.session`).
One token per world load. A `changelevel`, a `map` command and a process restart all tear the Lua
state down and rebuild it, so the load of this file is itself the world-load event; the guards below
exist only so a manual `lua_refresh_file` of this file re-executes the body without re-minting.
@copyright 2026 Limelight Gaming
@release CityRP 2
@author Limelight Development Team
@namespace cityrp.session
--]]--

cityrp.session = cityrp.session or {}

--- Unix timestamp of this world load, stable for the lifetime of the map.
--- Survives a hot reload of this file; a new map load mints a new one.
--- @type integer
cityrp.session.token = cityrp.session.token or os.time()

--- Whether this is the first world load of the current server process.
--- Engine-side, so it is the only process-scoped fact that outlives the Lua state teardown a
--- `changelevel` performs. Read live rather than cached, for the same reason.
--- @return boolean isFirst True on the process's initial map, false after any `changelevel`/`map`.
function cityrp.session.IsFirstMapOfProcess()
	return game.GetMapChangeCount() == 1
end

Notes on the shape:

  • x = x or default is safe here because a token is never falsy. Where a session field could legitimately be false, use the explicit if x == nil then form — sh_tophints.lua:30-33 and its comment are the in-repo precedent.
  • Do not wrap the derivation in hook.Add("Initialize", ...). Per AGENTS.md footgun #3 and the ~30 addon Initialize listeners catalogued in §2, an earlier hook returning non-nil would skip it, and the failure would be silent.
  • If the token is ever also needed clientside, name the file sh_session.lua — includeCore (sh_init.lua:68-88) only AddCSLuaFiles sh_/cl_ prefixes — and network the server's value rather than deriving os.time() independently on each client.

Correction to the todo doc's framing

todo/server-session-system.md (untracked, main checkout only) states the requirement as a value that "identifies one continuous run of the actual server process, and that reliably changes only when the process really restarts (not when Lua code is hot-reloaded)". Process lifetime is the wrong target for refund_ents, and this research is the evidence:

  • refund_ents asks "is this tracked entity still physically in the world?" That is a question about entities, and entities are destroyed by a changelevel — which does not restart the process.
  • A session id tied to process lifetime would therefore stay constant across a changelevel, and row.start_time == self.session would report every wiped entity as still alive. That is a new false-negative, in the opposite direction from the reload bug the doc set out to fix: the doc's stated fix would trade "refunds things it shouldn't" for "fails to refund things it should".
  • World-load lifetime is the correct predicate, and per §1 it coincides with Lua-state lifetime, so the file-load-time idiom the doc criticises is nearly right already. refund_ents' actual defect is narrower than the doc claims: not that os.time() is captured at file-load, but that it is captured unguarded, so a lua_refresh_file re-derives it. The guard in §"Recommended implementation" is the whole fix.

The doc's proposed API survives intact — cityrp.session.id, .startTime, .previous(), .isCurrent() are all still the right surface. Only the definition of what id tracks changes, and processId (§4) should not be added alongside it.

Unverified / open questions

  • todo/server-session-system.md does not exist. Resolved. It exists, untracked, in the main checkout at E:\Limelight\cityrp2\todo\server-session-system.md; it is invisible from a worktree because it was never committed. It has now been read, and it sharpens the verdict rather than changing it — see the note directly below.
  • Not tested live. Everything above is source reading plus wiki. I did not run a server, do a changelevel, or lua_refresh_file anything. In particular the claim that the Lua state is fully destroyed on changelevel rests on the GM:ShutDown wiki wording, which is strong but is documentation, not observation. Given this repo's own memory note that GMod diverges from assumption, a one-map-change smoke test (print a file-scope os.time() from a core library, changelevel, compare) would settle it cheaply and is worth doing before the map is finalised.
  • SysTime/CurTime across a map change — not established. Not needed for the recommendation.
  • game.GetMapChangeCount() reset on process restart — the wiki does not state it explicitly; I inferred it from "starts at 1" and "the server's session". Verify before relying on it for anything load-bearing.
  • Whether Limelight servers pass -disableluarefresh — the ticket asserts autorefresh is disabled; I found no startup-parameter or .cfg evidence in this repo either way (grep for AutoRefresh/lua_reload/disableluarefresh returns nothing outside antihack's unrelated sv_allowcslua checks). The recommendation does not depend on it — the guard covers manual lua_refresh_file regardless.
  • refund_ents/sv_init.lua:21 — I have read the unguarded token and its row.start_time == self.session consumer, but have not confirmed by test that a refresh of that file actually breaks refunds in practice (it may error out first, see §3).

Sources

This repository

  • gamemodes/cityrp_2_by_limelightgaming/gamemode/init.lua — :1-60 (bootstrap, LLServer), :109-111 (GM:ConnectToDatabase), :173-181 (GM:Initialize), :624-630 (GM:InitPostEntity), :2395-2402 (GM:ShutDown)
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/sh_init.lua — :11 (cityrp = {}), :68-88 (includeCore), :108-109 (libraries/metatables pass), :124-160 (plugin load loop)
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/cl_init.lua — :1019, :1200, :1356, :1432 (client Initialize/InitPostEntity listeners)
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/core/libraries/sh_plugin.lua — :75-96 (hook binding), :141-181 (SeedGlobal/AutoRegister/ClearGlobal), :186-200 (register)
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/core/libraries/sh_tophints.lua — :14, :27-33 (the guard idiom)
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/core/libraries/cl_temar.lua — :22, :68
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/core/plugins/refund_ents/sv_init.lua — :18-23, :96, :126, :157, :170, :179, :238
  • gamemodes/cityrp_2_by_limelightgaming/gamemode/core/plugins/carboosting/sv_init.lua — :6 (local PLUGIN = {}), :535 (file.Write persistence), :542-552 (hot-reload guard)
  • addons/ reload concommands: advduplicator/lua/autorun/server/AdvDupe.lua:1811, gmod_legs_3_112806637/lua/autorun/sh_legs.lua:418, rradio_3318060741/lua/rradio/server/sv_permanent.lua:336, stool_precisionalign/lua/weapons/gmod_tool/stools/precision_align.lua:225, warp/lua/weapons/gmod_tool/stools/warpdesttool.lua:359, lua/csl/custom_spawnlist.lua:34
  • AGENTS.md — footgun #3 (hook.Call ordering), #7 (duplicate registration), #22 (plugin key vs name)
  • docs/agents/artifacts.md:24 — research file path convention

Garry's Mod wiki

CityRP docs MCP

  • search_api("session boot token startup") — no public API matches; cityrp.session is greenfield (confirmed by grep -rn "cityrp.session" returning nothing repo-wide).