Is `GM:Initialize` the right world-load boot predicate for `cityrp.session`?
Last updated
May be stale
This page was last updated ago, on 2 September 2026. Check what it says against the code before relying on it.
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.luaregisters zero console commands and zerocityrp.command.add/cityrp.acommands.AddCommandentries. Its public surface isDiscover/Resolve/SeedGlobal/AutoRegister/ClearGlobal/register/call/get/IsAvailable/GetDependencyStatus/LoadSubResource*. None of them includeinit.luaor invoke a gamemode method.- The loader runs once, inline, at
gamemode/sh_init.lua:131-133(cityrp.plugin.Resolve()then thefor ... in ipairs(plugins)include loop). It is a file-scope loop, not a callable entry point — there is nocityrp.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 callInitialize. - ~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
RunStringin the gamemode is the developer console ingamemode/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 anif cityrp.topHints.shuffleHints == nil thenguard.
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.
SysTimeis "a highly accurate time in seconds since the start up" andCurTimeis "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.timeis 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 achangelevel(usingmapcommand is also detected) has occurred at any point in the server's session", and "will start at1". It is engine-side, so it survives the Lua teardown that nothing in Lua does.== 1means "first world load of this process". - It is an ordinal, not an identity. To turn it into a stable
processIdyou would have to mint one whengame.GetMapChangeCount() == 1and persist it to disk (the waycarboostingpersists its filing point withfile.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 forrefund_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.
Recommended implementation
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 defaultis safe here because a token is never falsy. Where a session field could legitimately befalse, use the explicitif x == nil thenform —sh_tophints.lua:30-33and its comment are the in-repo precedent.- Do not wrap the derivation in
hook.Add("Initialize", ...). Per AGENTS.md footgun #3 and the ~30 addonInitializelisteners catalogued in §2, an earlier hook returning non-nilwould 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) onlyAddCSLuaFilessh_/cl_prefixes — and network the server's value rather than derivingos.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_entsasks "is this tracked entity still physically in the world?" That is a question about entities, and entities are destroyed by achangelevel— which does not restart the process.- A session id tied to process lifetime would therefore stay constant across a
changelevel, androw.start_time == self.sessionwould 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 thatos.time()is captured at file-load, but that it is captured unguarded, so alua_refresh_filere-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
Resolved. It exists, untracked, in the main checkout attodo/server-session-system.mddoes not exist.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, orlua_refresh_fileanything. In particular the claim that the Lua state is fully destroyed onchangelevelrests on theGM:ShutDownwiki 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 (printa file-scopeos.time()from a core library,changelevel, compare) would settle it cheaply and is worth doing before the map is finalised. SysTime/CurTimeacross 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 at1" 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.cfgevidence in this repo either way (grepforAutoRefresh/lua_reload/disableluarefreshreturns nothing outside antihack's unrelatedsv_allowcsluachecks). The recommendation does not depend on it — the guard covers manuallua_refresh_fileregardless. refund_ents/sv_init.lua:21— I have read the unguarded token and itsrow.start_time == self.sessionconsumer, 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(clientInitialize/InitPostEntitylisteners)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,:68gamemodes/cityrp_2_by_limelightgaming/gamemode/core/plugins/refund_ents/sv_init.lua—:18-23,:96,:126,:157,:170,:179,:238gamemodes/cityrp_2_by_limelightgaming/gamemode/core/plugins/carboosting/sv_init.lua—:6(local PLUGIN = {}),:535(file.Writepersistence),: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:34AGENTS.md— footgun #3 (hook.Callordering), #7 (duplicate registration), #22 (plugin key vs name)docs/agents/artifacts.md:24— research file path convention
Garry's Mod wiki
- https://wiki.facepunch.com/gmod/GM:Initialize
- https://wiki.facepunch.com/gmod/GM:InitPostEntity
- https://wiki.facepunch.com/gmod/GM:ShutDown
- https://wiki.facepunch.com/gmod/Auto_Refresh
- https://wiki.facepunch.com/gmod/game
- https://wiki.facepunch.com/gmod/game.GetMapChangeCount
- https://wiki.facepunch.com/gmod/Global.SysTime
- https://wiki.facepunch.com/gmod/Global.CurTime
- https://wiki.facepunch.com/gmod/os.time
CityRP docs MCP
search_api("session boot token startup")— no public API matches;cityrp.sessionis greenfield (confirmed bygrep -rn "cityrp.session"returning nothing repo-wide).