Research: the boot predicate
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.
- Ticket: limelight-development/cityrp2#3432
- Parent map: #3431 — spec for a server-only
cityrp.sessionlibrary atgamemode/core/libraries/sv_session.lua. - Date: 2026-09-01
- Status: Research complete. Planning only — no gamemode code was changed.
All repo citations are path:line relative to the repo root. Engine claims are quoted from
wiki.facepunch.com/gmod and nothing else.
TL;DR
| Question | Answer |
|---|---|
Does GM:Initialize fire once per world load? |
Yes, and it re-fires on changelevel. It is a correct predicate. |
| Is it the best predicate? | No. The load of sv_session.lua itself is earlier, strictly-once-per-world-load, and free of hook-ordering hazards. GM:Initialize is the right place to announce/persist the session, not to derive it. |
Is InitPostEntity better? |
No — strictly worse. It is later than the plugin pass and can race the async DB-connect callback that the primary consumer runs on. |
| Can a reload re-enter it? | Not Initialize itself. But luarun, sinc, lua_refresh_file and cluarun can all re-execute a single file's scope, which is exactly the case the guard must cover. |
| Guard idiom? | cityrp.session = cityrp.session or {} plus per-field x = x or … / if x == nil then, as in sh_tophints.lua. sh_init.lua:11 (cityrp = {}) is what makes this a genuine world-load predicate. |
processId? |
Yes, cheaply and reliably — os.time() - RealTime() is the process start wall-clock, documented as surviving a map change and only resettable by a server restart. Do not drop that half. |
1. GM:Initialize — once per map load?
Engine behaviour (primary source)
GM:Initialize — verbatim:
Called after the gamemode loads and starts. No entities would be present at the time this hook is called, please see GM:InitPostEntity for a one time fire hook after all map entities have been initialized.
GM:InitPostEntity — verbatim:
Called after all the entities are initialized. Starting from this hook LocalPlayer will return valid object.
So the ordering is: gamemode files execute → Initialize → map entities spawn → InitPostEntity.
Both are tied to the gamemode load, and in GMod a gamemode is loaded once per level load. There is
no in-place "reload the gamemode" path in stock GMod: switching or reloading a gamemode goes through
a level load, which is why every restart path in this repo is a changelevel rather than anything
Lua-side:
gamemode/core/libraries/sv_temar.lua:89—temar.map()→RunConsoleCommand("changelevel", game.GetMap())gamemode/core/plugins/sv_auto_restart.lua:93— scheduled restart → samegamemode/init.lua:120— post-migration reboot → samegamemode/core/plugins/admin/sv_init.lua:1094,gamemode/core/sv_commands.lua:3831,gamemode/core/plugins/beta/sv_init.lua:345— admin map/restart commands → same
GM:ShutDown is documented in-repo as the other side of that: "Called when the server shuts down or
the map changes." (gamemode/init.lua:2393-2394). A changelevel therefore tears down and
re-runs the whole gamemode Lua state — Initialize fires again — while the srcds process itself
lives on. That is exactly the world-load-vs-process-lifetime split #3431 charted, and the repo
corroborates it independently (see §1.3).
This gamemode's Initialize
gamemode/init.lua:172-181:
--- Run the gamemode startup procedure.
function GM:Initialize()
self:ConnectToDatabase()
cityrp.disablesave = false
SetGlobalBool("disablesave", false)
return self.BaseClass:Initialize()
end
GM:ConnectToDatabase (gamemode/init.lua:109-130) creates DB_MAIN, runs pending migrations and
only then does hook.Run("DBConnected"). Two consequences that matter here:
DB_MAINdoes not exist beforeGM:Initialize. Anything that wants to write a session row must wait forDBConnected, not forInitialize.Initializecan end withoutDBConnectedever firing — a failed migration logsCriticaland returns (gamemode/init.lua:113-117), and an applied migrationchangelevels (gamemode/init.lua:118-122). SoInitializeis a boot predicate, never a boot guarantee.
GM:InitPostEntity (gamemode/init.lua:622-630) just snapshots ents.GetAll() into
self.entities.
1.3 The primary consumer already derives a session — and not from Initialize
refund_ents does not use GM:Initialize at all. gamemode/core/plugins/refund_ents/sv_init.lua:21-23:
PLUGIN.session = os.time()
PLUGIN.lastSession = 0
PLUGIN.server = LLServer
That is file scope during the plugin pass, which runs from gamemode/sh_init.lua:129-217,
i.e. after includeCore("libraries") (sh_init.lua:108) but before Initialize. The token is
then used as the start_time column on every tracked row (:Track at line 96, :Update at 126/157,
:Load at 169-170, :OnDBConnected at 238), and lastSession is resolved by a
MAX(start_time) … WHERE start_time < self.session query on DBConnected (lines 232-245).
The semantics are precisely "world load": on load, a row whose start_time equals the current
session is a still-standing entity that gets its ownership re-attached
(sv_init.lua:179-192); a row from an earlier session is a wiped entity that gets refunded
(sv_init.lua:193-210). If the Lua state survived changelevel, PLUGIN.session would never
change and nothing would ever be refunded. It does refund, therefore the state does not survive.
Independent in-repo confirmation of the map's core premise.
1.4 Verdict on the hook
GM:Initialize fires exactly once per world load and re-fires on changelevel, so it works. It
is nevertheless not the best available predicate, for three reasons:
- It is later than the existing consumer.
refund_entsreads its token at plugin file scope. A session library that only publishes its token onInitializecannot serve that call site without also restructuringrefund_ents— and every future plugin that wants a session id at registration time hits the same wall. hook.Callordering (AGENTS.md footgun #3). Allhook.Add("Initialize", …)callbacks run beforeGM:Initialize, and the first non-nilreturn wins and suppressesGM:Initializeentirely. Two library-levelInitializehandlers already exist —gamemode/core/libraries/network/sh_registry.lua:732(cityrp.network.Seal) andgamemode/core/libraries/network/sh_selftest.lua:275— and both returnniltoday (Sealends atsh_registry.lua:709with no return; its early exits are barereturns at 675/688). That is fine now, but it means the session token's existence would depend on a property of unrelated files that nothing enforces. A load-time derivation depends on nothing.Initializeis not reload-safe on its own anyway. It never re-fires on a single-file reload, so a library that derives its token there and is then hot-reloaded loses the token entirely (nil), which is a worse failure than re-deriving it. The guard is mandatory either way (§3).
1.5 Why not InitPostEntity
Strictly worse for this purpose:
- It is later than everything: plugin pass →
Initialize→InitPostEntity.refund_entsneeds a token during the plugin pass. - It races the DB.
DBConnectedfires from an async MySQL connect callback (gamemode/init.lua:111-127);refund_ents:OnDBConnected(sv_init.lua:231) issues thelastSessionquery the moment it lands. Whether that lands before or afterInitPostEntityis not deterministic. Deriving the token atInitPostEntitywould make thelastSessionresolution order-dependent on connection latency. - The repo already treats
InitPostEntityas unreliable to have observed, adding fallbacks for the hot-reload case:gamemode/core/plugins/daynight/sv_init.lua:323-324(timer.Simple(0, bootstrap)"Hot reload / late gamemode load: InitPostEntity may have already fired"),gamemode/core/plugins/spawnpoints/sv_init.lua:118(lazy cache fill for the same reason),gamemode/core/plugins/daynight/cl_init.lua:571. Building a session identity on a hook the codebase already has to work around is the wrong foundation. InitPostEntityis also the hookgame.GetIPAddressis documented as being too early for (see §4) — a signal that it is a "world is up" marker, not a "boot identity" marker.
PostGamemodeLoaded is a real GMod hook ("Called after the gamemode has
loaded.") and is used in this repo three
times (gamemode/core/metatables/sh_player.lua:45, :1151,
gamemode/core/metatables/sv_player.lua:568, plus gamemode/core/derma/sh_contextmenu.lua:1084),
but the repo never emits it itself and it has the same "later than the plugin pass" problem.
2. Can the plugin loader or hot-reload machinery re-enter the boot?
The loader itself: no
cityrp.plugin has no reload entry point. gamemode/core/libraries/sh_plugin.lua contains no
concommand, no Reload/Refresh function, and no re-invocation of includeCore. The only
includeCore call sites in the whole gamemode are its own definition and the two calls at
gamemode/sh_init.lua:108-109, both at file scope of sh_init.lua. Grepping the gamemode for
includeCore( returns only sh_init.lua:68/108/109 plus three comments. The core-library pass
runs exactly once per world load and there is no supported way to re-run it.
The two "reload"-flavoured things in sh_plugin.lua are comments, not machinery
(sh_plugin.lua:85, sh_plugin.lua:1334).
Engine autorefresh: disabled, and would not fire Initialize anyway
The Auto Refresh page documents that Lua scripts are
reloaded live on disk change, that "the entire file executes again", that hooks/net.Receive/
concommand.Add registrations from the old file survive unless explicitly removed, and that it is
disabled with the -disableluarefresh command-line parameter. Two things follow:
- A refresh re-runs file scope only. It does not re-fire
Initialize,InitPostEntity,PostGamemodeLoaded, or re-runsh_init.lua. Socityrp = {}(sh_init.lua:11) does not run. - Even with autorefresh off, the page documents a manual equivalent:
lua_refresh_file <path>. Treat this as live regardless of the-disableluarefreshstate until someone confirms otherwise on a Limelight box.
The manual reload vectors that actually exist in this repo
These are the ones the guard has to survive:
| Vector | Location | Effect |
|---|---|---|
luarun (! admin / console) |
gamemode/core/libraries/sv_admin_core.lua:202, handler at :150-201 |
RunString of arbitrary server Lua under xpcall. include("core/libraries/sv_session.lua") is one command away. |
sinc <name> |
gamemode/core/libraries/sv_temar.lua:419-431 |
include("temar/<name>.lua"), SteamID-gated. That scratch file can include any gamemode file. |
cluarun |
gamemode/core/libraries/cl_temar.lua:56-71 |
Same, clientside. Not relevant to a server-only library, but the same shape. |
inclua |
gamemode/core/libraries/cl_temar.lua:19-25 |
RunString(file.Read("run.lua", "LUA")), clientside. |
lua_refresh_file <path> |
engine | Re-executes one file's scope. |
Every one of these re-executes file scope without re-running sh_init.lua. That is the exact
threat model AGENTS.md footgun #7 describes ("assume it can fire more than once … and guard against
it explicitly").
Conclusion for Q2
Nothing can re-enter GM:Initialize short of a level load. But everything above can re-enter
sv_session.lua's file scope. So: the hook is not the risk; the file is.
3. Would sv_session.lua survive a reload of itself?
Why the answer is "yes, if guarded" — and the mechanism
gamemode/sh_init.lua:11 is the whole trick:
cityrp = {}
That line runs once per world load and only from sh_init.lua. It is not reached by luarun,
sinc, or lua_refresh_file on a library file. Therefore:
- World load →
cityrpis a brand-new table →cityrp.sessionisnil→ the library derives a new token. Correct: a new world load is a new session. - File reload →
cityrpis the same table it always was →cityrp.sessionis still there → the library must keep the existing token. Correct: a reload is not a new session.
So cityrp = {} is the world-load predicate, and it is one the library gets for free without
depending on any hook.
The established idiom
Two forms exist in the codebase, and they answer different problems.
(a) The "preserve across a re-include" idiom — gamemode/core/libraries/sh_tophints.lua:15-32:
cityrp.topHints = cityrp.topHints or {}
…
cityrp.topHints.cycleSeconds = cityrp.topHints.cycleSeconds or 15
cityrp.topHints.prefixes = cityrp.topHints.prefixes or {}
cityrp.topHints.hints = cityrp.topHints.hints or {}
-- Preserve an explicit false across hot reload (`x or true` would always force true).
if cityrp.topHints.shuffleHints == nil then
cityrp.topHints.shuffleHints = true
end
Note the second half: x = x or <default> is only safe when <default> is not the value you might
be preserving. For a false (or any falsy) value, the == nil form is required. sh_logging.lua:7
uses the same table-level form (cityrp.logging = cityrp.logging or {}).
(b) The "sweep the world clean once per (re)load" idiom —
gamemode/core/plugins/carboosting/sv_init.lua:543-552:
if not self.FilingNPCsByKey then
-- 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 even though the
-- entity itself is still standing in the world - sweep it clean once per (re)load so
-- the fresh spawn below doesn't leave a stale, now-untracked duplicate behind.
for _, ent in ipairs(ents.FindByClass("cityrp_boosting_fence")) do
if IsValid(ent) then ent:Remove() end
end
self.FilingNPCsByKey = {}
end
This is the fallback pattern: a plugin's state lives in a local PLUGIN = {} that a re-include
unavoidably replaces, so instead of preserving state it reconciles against the world. It exists
because a plugin's table is file-local; a core library hanging off the global cityrp table does
not have that problem and can use form (a) instead. Same file, sv_init.lua:4049-4054, documents
the matching hook caveat — CityRP.Core.PostPluginLoad only fires on a full boot, so a reloaded file
must also do its work immediately at file scope.
Recommended guard for sv_session.lua
Derive at file scope, behind an idempotent guard; use Initialize/DBConnected only to announce
and persist, never to derive.
--[[-- Server session identity (`cityrp.session`).
A *session* is one world load: the lifetime of the Lua state between level loads. A `changelevel`
ends one session and starts the next even though the srcds process survives; see
gamemode/init.lua:2393 (`GM:ShutDown`) and docs/research/2026-09-01-gm-initialize-boot-predicate.md.
@namespace cityrp.session
@state server
--]]--
cityrp.session = cityrp.session or {}
local log = cityrp.logging.GetLogger("session")
--- Wall-clock second at which THIS world load began.
--- Derived once per world load: `gamemode/sh_init.lua:11` (`cityrp = {}`) drops the previous value
--- on a level load, while a hot reload of just this file (luarun / sinc / lua_refresh_file) finds it
--- still set and keeps it. Never re-derive it -- consumers persist it as a DB key.
--- @type number
cityrp.session.id = cityrp.session.id or os.time()
--- Wall-clock second at which the srcds PROCESS started, stable across `changelevel`.
--- `RealTime()` is documented as server uptime that a map change does not reset, so
--- `os.time() - RealTime()` is the process start instant. Integer-truncated `os.time()` makes the
--- raw difference jitter by up to a second, so it is floored once and then preserved, exactly like
--- `id` -- recomputing it would produce a value one second adrift of the one already written to the
--- database.
--- @type number
cityrp.session.processId = cityrp.session.processId or math.floor(os.time() - RealTime())
--- True once the session has been announced to the database.
--- Explicitly `== nil`-guarded rather than `or false`: an `or` would silently re-arm the announce
--- on every hot reload (see sh_tophints.lua:30 for the same trap with a boolean default).
if cityrp.session.announced == nil then
cityrp.session.announced = false
end
--- @return number id The current world-load session id.
--- @nodiscard
function cityrp.session.Get()
return cityrp.session.id
end
--- @return number processId The srcds process start instant, stable across `changelevel`.
--- @nodiscard
function cityrp.session.GetProcessId()
return cityrp.session.processId
end
-- Announce/persist, not derive. DB_MAIN does not exist until GM:ConnectToDatabase has run
-- (gamemode/init.lua:110), and `DBConnected` may never fire at all if a migration fails or forces a
-- changelevel (gamemode/init.lua:113-122) -- so this side is best-effort and the id above is not.
-- Idempotent so a hot reload that re-adds the hook cannot double-write.
hook.Add("DBConnected", "cityrp.session.Announce", function()
if cityrp.session.announced then return end
cityrp.session.announced = true
log:Info(string.format("Session %d (process %d) started on %s.",
cityrp.session.id, cityrp.session.processId, game.GetMap()))
-- …persist here…
end)
Points worth keeping when this becomes real code:
hook.Addwith a fixed name is itself reload-safe. Re-adding the same event/name pair replaces the previous callback rather than stacking a second one, so theInitialize/DBConnectedregistration does not need its own guard — but the body still does, because on a hot reload the hook has already fired and will not fire again (footgun #7; thedaynighttimer.Simple(0, bootstrap)atsv_init.lua:324is the codebase's answer to that half).- Do not put the token in a
local. Alocal sessionId = os.time()at the top of the file is re-derived by every re-include with nothing to guard against it — that is the mistakecarboosting/sv_init.lua:544had to build a whole entity sweep to work around. os.time()has one-second resolution. Two world loads cannot land in the same second (a level load is far slower than that), so the id is safe as a session key — but see §5 for the collision case that is not safe.
4. Is a distinct processId cheaply expressible?
Candidates, against the wiki
| Candidate | Wiki wording | Survives changelevel? |
Changes on restart? | Usable? |
|---|---|---|---|---|
RealTime() |
"Returns the uptime of the game/server in seconds… Changing the map will not fix it like it does with CurTime. A server restart is necessary." | Yes — documented explicitly | Yes (resets to ~0) | Yes, as an offset |
SysTime() |
"Returns a highly accurate time in seconds since the start up… Returns: number Uptime of the server." |
Almost certainly (return doc says "uptime of the server", same family as RealTime), but "the start up" is not disambiguated between process and machine |
Yes | Only as a cross-check — the wording does not carry the guarantee RealTime's does |
os.clock() |
"Returns the approximate cpu time the application ran… different precision on Linux (1/100)." | Yes, but it is CPU time, not wall time — it advances at a load-dependent rate | Yes | No. Not monotone against wall clock; useless as an anchor |
CurTime() |
"…affected by various factors such as host_timescale… and the server being paused" |
No — the RealTime page says a map change does "fix" CurTime |
— | No |
engine.ServerFrameTime() |
"Returns an estimate of the server's performance. Equivalent to calling FrameTime from the server" |
n/a | n/a | No. A per-frame duration, carries no identity |
game.GetIPAddress() |
"Returns 0.0.0.0:port on the server when called too early, including in GM:Initialize and GM:InitPostEntity. This bug seems to only happen the first time a server is launched, and will return the correct value after switching maps." |
Constant per host | No — identical across restarts | No, twice over: constant and wrong at exactly the moment you'd read it |
| hostname / port | — | Constant | No | No |
Verdict: yes, and the recipe is one line
local processId = math.floor(os.time() - RealTime())
RealTime() is documented as server uptime that a map change does not reset. os.time() is wall
clock. Their difference is therefore the wall-clock instant the srcds process started — constant
for the whole life of the process, across any number of changelevels, and different for the next
process (unless a restart completes within the same second, which is not physically possible for a
GMod server boot).
Caveats to carry into implementation:
os.time()is integer-truncated, so the raw difference jitters by up to a second between calls. Compute once, floor, preserve (as in the §3 sketch). Never recompute for comparison against a stored value.RealTime()is documented to lose precision "if the uptime is more than 30+(?) days" and "cease to be functional after 50+(?) days". Limelight servers restart on a timer (gamemode/core/plugins/sv_auto_restart.lua), so a 30-day uptime is not a realistic regime — but the derivedprocessIdwould drift on a box that did somehow run that long. Since it is preserved rather than recomputed, drift affects a future derivation only, not an existing session's value.- Do not substitute
SysTime()forRealTime()here. It is very likely identical in behaviour and its own docs say "Uptime of the server", but the "since the start up" phrasing does not disambiguate process from machine and the ticket explicitly asked not to assume it.RealTime()carries the guarantee in writing; use it.
Recommendation: keep the processId half of the spec. It is one arithmetic expression, it is
backed by an explicit wiki sentence, and it gives cityrp.session the ability to answer "same
process, different world" — which is precisely the distinction #3431 identified as the thing
refund_ents gets wrong today.
5. Things that contradict the map's premises, or open new questions
(a) The map's premise holds, but the existing token is not what the map assumes it is.
#3431 frames this as choosing a boot predicate. refund_ents already has one, and it is file
scope, not GM:Initialize (refund_ents/sv_init.lua:21). That is actually the better predicate,
just unguarded. The migration is therefore smaller than "adopt a hook" — it is "hoist the existing
file-scope derivation into a guarded library and have refund_ents read it". Worth reflecting in
the spec.
(b) Latent bug — refund_ents compares a DB value to a Lua number with ==.
refund_ents/sv_init.lua:179:
local current = row.start_time == self.session
self.session is a Lua number (os.time(), line 21). row.start_time comes straight out of a
DB_MAIN:FRun result set. If that column arrives as a string — which MySQL result marshalling
commonly does — this comparison is false for every row, and every still-standing entity is
treated as wiped and refunded instead of re-owned, duplicating the item. The same shape appears at
line 241 (self.lastSession = results[1].start_time, which then feeds a %s format at line 169).
I did not verify the marshalling behaviour of DB_MAIN — that needs a live check — but if
cityrp.session is going to own this token, it should own its type too and hand consumers a
tonumber-normalised comparison helper rather than leaving each call site to get the coercion right.
This deserves its own Bug issue regardless of #3431's outcome.
(c) Initialize is not a boot guarantee, only a boot predicate.
GM:ConnectToDatabase (gamemode/init.lua:113-122) has two paths that end the boot without ever
firing DBConnected: a failed migration (logs Critical, returns) and an applied migration
(changelevel back into a fresh boot). Any session bookkeeping that must happen has to tolerate
"session started, never announced". The §3 sketch splits derive-from-announce for this reason.
(d) lua_refresh_file may still be live even with -disableluarefresh.
The Auto Refresh page documents the manual command
separately from the automatic behaviour and does not say the parameter disables it. Worth a
one-command check on a staging box; the guard covers it either way.
(e) hook.Call ordering is a live, unenforced dependency for anything on Initialize.
Both existing library-level Initialize handlers (network/sh_registry.lua:732,
network/sh_selftest.lua:275) currently return nil, so GM:Initialize runs. Nothing in the repo
enforces that, and the day one of them starts returning a value, GM:ConnectToDatabase silently
stops being called (AGENTS.md footgun #3). That is a much bigger problem than cityrp.session — it
would take the database with it — and is arguably worth its own issue: a lint rule or a comment on
each Initialize handler stating that it must never return.
Sources
Repo (this worktree, branch research/3432-boot-predicate off 1ea7659af)
gamemode/sh_init.lua:11, 68-90, 108-109, 129, 217gamemode/init.lua:109-130, 172-181, 622-630, 2393-2402gamemode/core/plugins/refund_ents/sv_init.lua:21-23, 96, 126, 157, 169-210, 231-245gamemode/core/libraries/sh_tophints.lua:15-32gamemode/core/plugins/carboosting/sv_init.lua:543-552, 4049-4055gamemode/core/libraries/sh_logging.lua:7gamemode/core/libraries/sv_admin_core.lua:150-202gamemode/core/libraries/sv_temar.lua:87-90, 419-431gamemode/core/libraries/cl_temar.lua:19-25, 56-71gamemode/core/libraries/network/sh_registry.lua:674-709, 732gamemode/core/libraries/network/sh_selftest.lua:275gamemode/core/plugins/daynight/sv_init.lua:304-324gamemode/core/plugins/spawnpoints/sv_init.lua:118, 275gamemode/core/plugins/sv_auto_restart.lua:93AGENTS.mdfootguns #3, #7, #18, #22
(all repo paths above are relative to gamemodes/cityrp_2_by_limelightgaming/ except AGENTS.md)
Garry's Mod Wiki
- https://wiki.facepunch.com/gmod/GM:Initialize
- https://wiki.facepunch.com/gmod/GM:InitPostEntity
- https://wiki.facepunch.com/gmod/GM:PostGamemodeLoaded
- https://wiki.facepunch.com/gmod/Global.RealTime
- https://wiki.facepunch.com/gmod/Global.SysTime
- https://wiki.facepunch.com/gmod/Global.CurTime
- https://wiki.facepunch.com/gmod/os.clock
- https://wiki.facepunch.com/gmod/engine.ServerFrameTime
- https://wiki.facepunch.com/gmod/game.GetIPAddress
- https://wiki.facepunch.com/gmod/Auto_Refresh