CityRP Documentation Repository

Research: the boot predicate

Last updated

  • Ticket: limelight-development/cityrp2#3432
  • Parent map: #3431 — spec for a server-only cityrp.session library at gamemode/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 → same
  • gamemode/init.lua:120 — post-migration reboot → same
  • gamemode/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:

  1. DB_MAIN does not exist before GM:Initialize. Anything that wants to write a session row must wait for DBConnected, not for Initialize.
  2. Initialize can end without DBConnected ever firing — a failed migration logs Critical and returns (gamemode/init.lua:113-117), and an applied migration changelevels (gamemode/init.lua:118-122). So Initialize is 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:

  1. It is later than the existing consumer. refund_ents reads its token at plugin file scope. A session library that only publishes its token on Initialize cannot serve that call site without also restructuring refund_ents — and every future plugin that wants a session id at registration time hits the same wall.
  2. hook.Call ordering (AGENTS.md footgun #3). All hook.Add("Initialize", …) callbacks run before GM:Initialize, and the first non-nil return wins and suppresses GM:Initialize entirely. Two library-level Initialize handlers already exist — gamemode/core/libraries/network/sh_registry.lua:732 (cityrp.network.Seal) and gamemode/core/libraries/network/sh_selftest.lua:275 — and both return nil today (Seal ends at sh_registry.lua:709 with no return; its early exits are bare returns 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.
  3. Initialize is 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_ents needs a token during the plugin pass.
  • It races the DB. DBConnected fires from an async MySQL connect callback (gamemode/init.lua:111-127); refund_ents:OnDBConnected (sv_init.lua:231) issues the lastSession query the moment it lands. Whether that lands before or after InitPostEntity is not deterministic. Deriving the token at InitPostEntity would make the lastSession resolution order-dependent on connection latency.
  • The repo already treats InitPostEntity as 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.
  • InitPostEntity is also the hook game.GetIPAddress is 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-run sh_init.lua. So cityrp = {} (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 -disableluarefresh state 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 → cityrp is a brand-new table → cityrp.session is nil → the library derives a new token. Correct: a new world load is a new session.
  • File reload → cityrp is the same table it always was → cityrp.session is 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.

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.Add with 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 the Initialize/DBConnected registration 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; the daynight timer.Simple(0, bootstrap) at sv_init.lua:324 is the codebase's answer to that half).
  • Do not put the token in a local. A local 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 mistake carboosting/sv_init.lua:544 had 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 derived processId would 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() for RealTime() 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, 217
  • gamemode/init.lua:109-130, 172-181, 622-630, 2393-2402
  • gamemode/core/plugins/refund_ents/sv_init.lua:21-23, 96, 126, 157, 169-210, 231-245
  • gamemode/core/libraries/sh_tophints.lua:15-32
  • gamemode/core/plugins/carboosting/sv_init.lua:543-552, 4049-4055
  • gamemode/core/libraries/sh_logging.lua:7
  • gamemode/core/libraries/sv_admin_core.lua:150-202
  • gamemode/core/libraries/sv_temar.lua:87-90, 419-431
  • gamemode/core/libraries/cl_temar.lua:19-25, 56-71
  • gamemode/core/libraries/network/sh_registry.lua:674-709, 732
  • gamemode/core/libraries/network/sh_selftest.lua:275
  • gamemode/core/plugins/daynight/sv_init.lua:304-324
  • gamemode/core/plugins/spawnpoints/sv_init.lua:118, 275
  • gamemode/core/plugins/sv_auto_restart.lua:93
  • AGENTS.md footguns #3, #7, #18, #22

(all repo paths above are relative to gamemodes/cityrp_2_by_limelightgaming/ except AGENTS.md)

Garry's Mod Wiki