CityRP Documentation Repository

Research: FearlessRP prior art

Last updated

  • Ticket: limelight-development/cityrp2#3433
  • 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.

Prior art read in full: E:\FearlessRP\CityRP\plugins\sessions\sh_plugin.lua (7 lines) and sv_init.lua (246 lines). It is a design reference, not a port — it targets a mysql:Insert():Where():WhereNull():Callback() builder that does not exist in CityRP2, where the API is raw DB_MAIN:Run(sql, cb) plus numbered migrations under gamemode/core/migrations/.

Repo citations are path:line relative to gamemodes/cityrp_2_by_limelightgaming/ unless the path starts docs/ or is AGENTS.md. FearlessRP citations are prefixed FRP . Engine claims are quoted from wiki.facepunch.com/gmod and nothing else.


TL;DR

# Item Verdict One line
1 Pending-connection retry (30s per-SteamID64 timer) Drop It works around a race CityRP2 does not have — PlayerInitialized already is the "row is loaded" signal. Copying it would import an uncancellable retry loop and a build/event-server infinite spin.
2 Bot / singleplayer / LAN SteamID literals Adapt The four-literal filter collapses to one ply:IsBot() if we hook PlayerInitialized. STEAMID_0:0:0 is confirmed a typo against the wiki — the literal is STEAM_0:0:0.
3 sv_hibernate_think dependency Adapt (downgrade to a note) The engine constraint is real and verified, and Limelight only forces the ConVar as a side effect of the Discord relay. But phase one never reads the heartbeat back, so a missed beat on an empty server is cosmetic, not fatal. Log Debug, never Error.
4 sentry.UUID4() + hand-rolled fallback Adapt sentry does not exist in CityRP2 — its include is commented out (gamemode/init.lua:101). Use util.SHA256 over a load-time entropy mix; math.random alone is unsafe because the gamemode never seeds it.
5a ShutDown close-out Carry, with a token guard GM:ShutDown covers shutdown and changelevel, which is exactly the world-load boundary. FearlessRP's version would hard-error on a nil token; ours must early-return.
5b PlayerChangedTeam close-and-reopen Drop The code confirms the map's objection: it fires per team change with no dedupe, fragments playtime into N rows, and writes reason = "Team Change" into a column that otherwise means why the player left.
5c DatabaseConnected hook Carry (rename) CityRP2's equivalent is the DBConnected hook (gamemode/init.lua:124), used by 12 existing call sites.
5d SetupHeartbeat timer.Exists guard Carry Correct AGENTS.md footgun #7 re-entrancy guard, and cheap.
5e Fatal-then-changelevel Drop Out of scope per the map, and the local variant (token nil → reboot) is a self-inflicted restart on a logging failure.
5f Error handling on failed inserts Adapt — this is the important one DB:Run invokes its callback even when MySQL errors (gamemode/db.lua:139-156). Fail-closed is impossible on Run; cityrp.session must use DB:RunChecked.
5g Opaque token as the join key Drop FearlessRP joins player_sessions to server_sessions on the 64-char token. The map's token + autoincrement split exists precisely to avoid this.

1. The pending-connection retry

What FearlessRP does

FRP sv_init.lua:135-171. On player_connect2 it SELECTs players by _SteamID64. On a miss it logs "Player record not found … waiting for creation", arms timer.Create("CityRP.Plugin.sessions.WaitForPlayer." .. sid64, 30, 1, …), records the timer id in PLUGIN.pendingConnections[sid64], and on fire re-enters player_connect2 from the top — so a miss re-arms indefinitely. FRP sv_init.lua:213-220 cancels the timer on player_disconnect2.

The race it is actually working around

player_connect2 fires at connect time — the client has not spawned, has not authed, and has no Player entity yet. FearlessRP's session insert wants a players row to hang off. For a new player that row does not exist yet, and for an existing one the read may still be racing whatever creates or updates it. The retry is therefore a poll for "has the account row appeared yet", with 30 seconds chosen as longer than a plausible map download. It is a workaround for using a connect-time gameevent as a proxy for a data-loaded signal.

Does CityRP2 have the same race?

No — and the reason is structural. Three things differ.

(a) player_connect2 is not a gameevent here; it is a gamemode hook the repo emits itself. gamemode/core/libraries/sv_gatekeeper.lua:12-13 listens to the real gameevents player_connect / player_disconnect; :57-77 runs PlayerPasswordAuth, may drop the client, and only then does hook.Call("player_connect2", GAMEMODE, data.name, data.networkid, data.address, data.userid) (:76). The disconnect side is :79-90, gated on a connected[userid] flag set at :75.

(b) CityRP2 has an explicit "the row is loaded" signal, and onlinelog already uses it. The chain is:

Step Location
GM:PlayerInitialSpawn sets up handshake state, defers one frame gamemode/init.lua:1784-1806
ply:LoadData() gamemode/init.lua:1804
LoadData reads the preload cache or SELECT * FROM players WHERE _UniqueID = … gamemode/core/metatables/sv_player.lua:1589-1603
hit → LoadData2 → hook.Call("PlayerDataLoaded", GAMEMODE, self, true) sv_player.lua:1623, 1710
miss → LoadDataNew → hook.Call("PlayerDataLoaded", GAMEMODE, self, false) then SaveData(true) sv_player.lua:1715-1725
GM:PlayerDataLoaded fires hook.Call("PlayerInitialized", GAMEMODE, ply) gamemode/init.lua:1735

onlinelog hangs its row insert on exactly that: gamemode/core/plugins/onlinelog/sv_init.lua:95 — hook.Add("PlayerInitialized", "onlinetimeloginit", playerinit) — and playerinit (:81-94) reads ply:IPAddress(), ply.cityrp._SteamID and ply.cityrp._TimePlayed, all of which are only populated by the load. Its second hook, hook.Add("PlayerSpawnFirstSpawn", "net_runsidcheck_init", …) (:97-100), is a different job — it nets the client-side-ID check, which needs the client's Lua up, not the DB row. So the split is: PlayerInitialized = server data ready; PlayerSpawnFirstSpawn = client ready. A session ledger wants the former.

(c) There is also an earlier prefetch, which is why the race is doubly absent. gamemode/core/libraries/sv_player.lua:455-488 prefetches the row on PlayerAuthed into cityrp.playerpreload, deliberately keyed off PlayerAuthed rather than player_connect2 — and the comment at :448-454 says why in as many words: "The previous version of this was disabled ('got abused') because it keyed off player_connect2, which fires pre-auth and is spoofable/spammable." The repo has already made, and documented, the exact decision this ticket is asking about.

The ordering caveat that replaces it

There is one real ordering fact to design around, and it is the opposite shape to FearlessRP's: for a first-time player, PlayerInitialized fires before the players row exists. LoadDataNew (sv_player.lua:1715-1725) calls PlayerDataLoaded at :1721 and only then SaveData(true) at :1724, whose INSERT INTO players … is at :1743. Since DB_MAIN is a single connection, a session insert issued from a PlayerInitialized handler is queued ahead of the players insert. Consequence for #3436 / #3442: player_sessions.steam_id must not carry a foreign key to players, or every genuinely-new player's first session row fails. Store the SteamID as a plain indexed column, exactly as onlinelog does today (onlinelog/sv_init.lua:93, steamid with no FK).

And the second-order reason not to poll for a players row at all: SaveData(create) early-returns when LLServer == "build" or LLServer == "event" or cityrp.disablesave (sv_player.lua:1736-1739). On the build and event servers no players row is ever created for a new player, so a faithful port of FearlessRP's retry would re-arm its 30-second timer forever for every new joiner there.

Latent bugs in the retry worth not copying

  • The cancel can lose the race it exists to win. The timer is armed inside an async SELECT callback (FRP sv_init.lua:146-164). If the player disconnects while that SELECT is in flight, player_disconnect2 finds pendingConnections[sid64] empty and cancels nothing; the callback then arms a timer for a departed player that nothing will ever clear. This is AGENTS.md footgun #2 verbatim: the callback never re-validates that the connection is still live.
  • No attempt cap. The retry re-enters player_connect2, so a permanent miss is an unbounded 30-second loop, not a bounded retry.
  • One slot per SteamID64. pendingConnections[sid64] is overwritten by a fast reconnect, so the first connection's disconnect cancels the second connection's timer.

Verdict: Drop

cityrp.session should key player sessions on PlayerInitialized and PlayerDisconnected (the shape onlinelog/sv_init.lua:95, 111 already uses and phase two is replacing), with no retry, no pre-check SELECT, and no pendingConnections table. The row is guaranteed loaded at that point, which is the entire thing the retry was buying.


2. Bot / singleplayer / LAN SteamID handling

What FearlessRP does

  • Connect (FRP sv_init.lua:136-139): if sid == "BOT" or sid == "STEAMID_0:0:0" or sid == "STEAM_ID_PENDING" or sid == "STEAM_ID_LAN" then return end
  • Disconnect (FRP sv_init.lua:201-208): "BOT" and "STEAM_0:0:0" each ErrorNoHalt and return.

The literals, from primary source

gameevent/player_connect documents networkid verbatim as:

The SteamID the player has. Will be BOT for bots, STEAM_0:0:0 or STEAM_ID_PENDING in single-player, and STEAM_ID_LAN when using the -multirun client option.

So the real set is exactly BOT, STEAM_0:0:0, STEAM_ID_PENDING, STEAM_ID_LAN. STEAMID_0:0:0 on the connect path is a typo and matches nothing — verified. The disconnect path's STEAM_0:0:0 is the correct spelling. The connect-path bug is not harmless: the singleplayer host falls through to util.SteamIDTo64(sid), which the wiki documents as returning "64bit SteamID or 0 (as a string) on fail" — i.e. a session row keyed "0", or worse a plausible but wrong id. The same page notes address is "none" for bots and "loopback" for listen-server/singleplayer hosts, if a second discriminator is ever wanted.

What CityRP2 already does

  • Bots never reach player_connect2 at all. sv_gatekeeper.lua:58-60 returns early for data.networkid == "BOT" before the hook.Call at :76. The disconnect side is gated on the connected[userid] flag (:75, :86-89), which was never set for that bot — so player_disconnect2 does not fire for bots either. FearlessRP's disconnect-side ErrorNoHalt for bots (FRP sv_init.lua:201-204) is unreachable noise here.
  • The singleplayer/LAN literals are not filtered by the gatekeeper. They would reach player_connect2. Irrelevant to Live, relevant to a developer running a listen server.
  • The idiomatic repo filter is ply:IsBot(), used at sh_access.lua:125, sv_access.lua:110, sv_team.lua:173, 179, discord_relay_free/sv_init.lua:352, weapons_on_back/sv_init.lua:212, itemgrantmenu/sv_init.lua:70 and gamemode/init.lua:1846, among others. Bots do reach PlayerInitialized, so the guard is required, not optional.

Verdict: Adapt

Hooking PlayerInitialized rather than the gameevent collapses the whole problem: we have a real Player, so the filter is one line —

if not IsValid(ply) or ply:IsBot() then return end

— matching the repo's existing idiom. If a future consumer does need the raw gameevent path, use a shared literal set { BOT, STEAM_0:0:0, STEAM_ID_PENDING, STEAM_ID_LAN } defined once (AGENTS.md footgun #11 — never as two divergent copies on two code paths, which is precisely how FearlessRP acquired its typo).

Do not carry ErrorNoHalt for these cases. A bot connecting is not an error condition; the repo's own cityrp.logging at Debug is the right channel (AGENTS.md footgun #8).


3. The sv_hibernate_think dependency

What FearlessRP does

FRP sv_init.lua:11 caches GetConVar("sv_hibernate_think"); :47-56 warns on DatabaseConnected that a disabled hibernate think means no heartbeat and "the server may automatically restart upon the first player connecting", escalating to logger:Error when cityrpserver["Environment"] == "production".

The engine behaviour, from primary source

timer.Create, verbatim:

Timers use CurTime internally. Due to this, they won't advance while the client is timing out from the server or on an empty dedicated server due to hibernation. (unless sv_hibernate_think is set to 1 or delay is set to 0).

GM:Tick documents the same for the tick hook — it does not run on an empty server unless the ConVar is 1. The constraint is real and confirmed.

Note the scope precisely: hibernation only applies to an empty dedicated server. The heartbeat stalls while nobody is connected and resumes on the next join. FearlessRP's failure mode follows: the last beat is stale by however long the server sat empty, its remote kill-switch reads that as "session closed", and the first joiner triggers a changelevel.

Does it apply to Limelight?

Partly, and by accident. Grepping the whole worktree for hibernate returns exactly two lines, both in one plugin:

gamemode/core/plugins/discord_relay_free/sv_init.lua:418: print("Forcing 'sv_hibernate_think' to '1'")
gamemode/core/plugins/discord_relay_free/sv_init.lua:419: game.ConsoleCommand("sv_hibernate_think 1\n")

Set from hook.Add("InitPostEntity", "cityrp.discord.fixthinking", …) (:416-421), gated on cityrp.discord.config.noBots, which is hardcoded true at :61. So on any server where the Discord relay loads, hibernate think is forced on — but that is a Discord-relay implementation detail, not a session-system guarantee. Nothing in the repo declares it, no .cfg in the worktree sets it, and the plugin could be disabled per-server via .meta.lua conditions (AGENTS.md footgun #19) without anyone connecting the two. cityrp.session must not silently depend on another plugin's side effect.

Does phase one need a reliable heartbeat?

No. The map is explicit: the heartbeat "is never read back to trigger a reboot", and the remote restart kill-switch is Out of scope. Its stated justification is player-session playtime integrity in phase two. And a stale beat on an empty server is exactly the case where playtime integrity is vacuous — nobody is playing. The heartbeat's real job is answering "when did this session last prove it was alive", which a hibernating empty server answers correctly by not beating.

Verdict: Adapt — keep the check, downgrade the severity

Carry a one-time observation of GetConVar("sv_hibernate_think"):GetBool() at heartbeat setup, but:

  • Log it at Debug, not Warning/Error. There is no cityrpserver["Environment"] key in this repo (LLServer is the environment discriminator, gamemode/init.lua:39-50), and there is no production consequence in phase one to escalate for.
  • Read the ConVar at setup time rather than caching a GetConVar handle at file scope — the relay sets it from InitPostEntity, which is later than the library pass.
  • Record it as a precondition on the kill-switch follow-up, not on this map. Whoever picks that feature up needs sv_hibernate_think 1 to be a declared, server-config-level guarantee before any code reads closed_at back — and needs to notice that today it is provided incidentally by the Discord relay.

4. Token generation

What FearlessRP does

FRP sv_init.lua:18-42: sentry.UUID4() if sentry exists, else a 64-character string drawn from a 62-symbol alphanumeric alphabet via math.random.

Does sentry exist in CityRP2?

No. gamemode/sentry.lua is present (14 lines) but its include is commented out — gamemode/init.lua:101: --include("sentry.lua"). Its only content is a sentry.Setup(…) DSN call; it exposes no UUID4. Grepping the whole worktree for sentry outside addons/ returns nothing but Photon vehicle model paths. The if sentry then branch is dead here — plan for the fallback shape only.

What already exists in CityRP2

cityrp-docs search_api for "random token uuid generate unique id" returns no public API matches. Grepping gamemode/ for UUID, randomseed and util.CRC finds only playermodel_skins/cl_export.lua:95 and waypoints/sv_init.lua:483 (both util.CRC for cache keys, not identity) and steamgroup/sv_init.lua:26 (util.SteamIDTo64). There is no token/UUID helper in this codebase. The closest prior art is onlinelog's own generate_token (onlinelog/sv_init.lua:63-79) — a 10-character math.random draw over a 62-symbol alphabet, i.e. the same shape as FearlessRP's fallback, and the thing phase two replaces.

Why math.random alone is not enough

math.randomseed, verbatim:

Seeds the random number generator. The same seed will guarantee the same sequence of numbers each time with math.random.

math.randomseed is never called anywhere in gamemode/ (verified by grep). Whether the engine seeds the generator per process is not documented on the wiki — treat it as unverified. If it does not, every server in the fleet draws the identical sequence from boot, and cityrp.session's token would be the same string on every restart of every server. Even a per-process seed is a weak distinguisher when several servers boot together after a fleet deploy. The map's requirement is a token available synchronously at library load time, before the DB connects, so we cannot lean on the autoincrement to disambiguate at generation time.

The synchronous entropy actually available at library load

Libraries load from gamemode/sh_init.lua's includeCore("libraries") pass, which gamemode/init.lua:56 reaches after :20-50. So all of the following are already populated by then — verified by the fact that init.lua itself uses them at :20-52:

Source Available at Contributes
LLServer gamemode/init.lua:39, 50 (set before include("sh_init.lua") at :56) which server in the fleet
game.GetMap() used at gamemode/init.lua:25 which map
GetConVar("hostname"):GetString() used at gamemode/init.lua:20 which host
os.time() 1-second resolution wall clock when
SysTime() "a highly accurate time in seconds since the start up … updated any time the function is called, allowing for sub-think precision" (wiki) sub-second jitter
os.clock() process CPU time independent jitter
tostring({}) table address per-allocation, heap-layout-derived

Deliberately not available: game.GetIPAddress(), documented as returning "0.0.0.0:<port>" when called too early, "including in GM:Initialize and GM:InitPostEntity" (wiki) — so certainly useless at library load. system.SteamTime is documented as not working on dedicated servers (wiki).

Verdict: Adapt

Concrete recommendation — hash a mix, do not draw from an alphabet:

-- Synchronously available at library load, before DB_MAIN exists.
local token = util.SHA256(table.concat({
    tostring(LLServer),
    game.GetMap(),
    GetConVar("hostname"):GetString(),
    os.time(),
    SysTime(),
    os.clock(),
    tostring({}),          -- table address: per-allocation entropy
    math.random(),         -- contributes if seeded, harmless if not
}, "|")):sub(1, 32)

util.SHA256, verbatim: "Generates the SHA-256 hash of the specified string. This is mostly unique and is astronomically unlikely to return the same hash for a different string unlike util.CRC or util.MD5 which are both much more vulnerable to duplicate returns." Shared realm, so it exists on the server; synchronous; no dependency.

Notes for the downstream tickets:

  • math.random is an ingredient, never the mechanism. If it turns out unseeded, the hash is still distinct because LLServer, hostname, SysTime, os.clock and the table address are. Never call math.randomseed from a library — the wiki warns "Usage of this function affects ALL random numbers in the game" and this gamemode currently leaves the global generator alone.
  • 32 hex characters, not 64. Half a SHA-256 is 128 bits, far past collision relevance for a fleet of a dozen servers, and halves the index width on every player_sessions row — see §5g.
  • The token is the local identity; the DB autoincrement remains the ordering key, per the map.

5. Everything else in the two files

5a. The ShutDown close-out — Carry, with a token guard

FRP sv_init.lua:233-246 closes both the server session and every open player session with reason = "Server Shutdown".

GM:ShutDown, verbatim: "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 is exactly the world-load boundary #3431 settled on, and the repo says the same in its own words at gamemode/init.lua:2393-2394. Two ShutDown handlers already exist (gamemode/core/libraries/sv_logs.lua:188-193, and GM:ShutDown at gamemode/init.lua:2395-2402), both of which issue DB_MAIN:Run writes at shutdown — so this pattern is already load-bearing here and is known to land.

Two changes on the way in:

  • Guard the token. FearlessRP's ShutDown never checks self.token ~= nil. With a query builder that produces a broken WHERE; with CityRP2's raw string concatenation it is a "attempt to concatenate a nil value" hard error inside ShutDown, which would take sv_logs.lua:188 and GM:ShutDown's player saves down with it depending on hook order. Fail closed: if not cityrp.session.IsReady() then return end.
  • Do not treat a missing close-out as a crash signal. The map already ruled crash-vs-clean irrelevant to refund_ents; closed_at IS NULL is a reporting nicety, not a predicate anything branches on in phase one.

5b. PlayerChangedTeam close-and-reopen — Drop; the code confirms the map's objection

FRP sv_init.lua:114-133 closes the player's open session with reason = "Team Change" and, in that update's callback, inserts a fresh one with the new team_id. The map already rejected this shape in favour of a player_session_jobs child table. The source corroborates it on four counts:

  1. It overloads reason. The same column holds "Team Change" and "Disconnect: " .. reason (FRP sv_init.lua:225) and "Server Shutdown" (:242) — so "why did this row close" answers two unrelated questions and no consumer can distinguish "player left" from "player changed job" without string matching.
  2. No dedupe, no rate limit. CityRP2 fires PlayerChangedTeam from sv_team.lua:97 and :167, and eight plugins already hook it. A player cycling jobs generates a row pair per cycle. One session becomes N rows whose union you must reconstruct to answer "how long was this player on".
  3. The insert is unconditional. It runs in the update's callback regardless of whether the update matched anything (FRP sv_init.lua:124-131). If the open session never existed — the pending-retry from §1 still in flight, say — a team change creates one from nothing, with the wrong opened_at.
  4. AGENTS.md footgun #2. The callback closes over sid64 and never re-validates; a player who disconnects between the update and its callback gets a fresh open row inserted after their disconnect handler already ran, which is then only closed at ShutDown.

A player_session_jobs child table (session_row_id, team_id, started_at, ended_at) has none of these: one parent row per connection, an append-only interval log beneath it, and reason retains a single meaning.

5c. The DatabaseConnected hook — Carry, renamed

CityRP2's equivalent is hook.Run("DBConnected"), fired from GM:ConnectToDatabase at gamemode/init.lua:124, and it is the established way to wait for the DB — 12 existing call sites: meska/hooks.lua:13, sv_logs.lua:196, admin/sv_init.lua:215, bans/sv_init.lua:29, clans/sv_init.lua:659, doors/sv_init.lua:61, giveprops/sv_init.lua:109, policedb/sv_init.lua:357, queue/sv_init.lua:40, refund_ents/sv_init.lua:246, sv_item_db_update.lua:77, gamemode/init.lua:146.

One caveat carried from #3432 and worth restating for the fail-closed design: DBConnected is not guaranteed to fire. GM:ConnectToDatabase (gamemode/init.lua:112-125) has two paths that end the boot without it — a failed migration logs Critical and returns (:113-117), and an applied migration changelevels (:118-122). "Session derived, never persisted" is a state cityrp.session must serve IsReady() == false for, indefinitely and quietly.

5d. The SetupHeartbeat timer.Exists guard — Carry

FRP sv_init.lua:79-81. A named timer plus an existence check before timer.Create is the textbook AGENTS.md footgun #7 guard, and matters more here than there: DBConnected is a hook.Run, and nothing prevents a second ConnectToDatabase (a reconnect path, DB:Cache/DB:Connect at gamemode/db.lua:142-145) from firing it again. Keep the constant timer name on the library table (cityrp.session.heartbeatTimer), not as a repeated literal.

For the interval: FearlessRP beats every 20 seconds. There is no phase-one reader, so anything from 30–60s is defensible; onlinelog's existing playtime updater runs at 60 (onlinelog/sv_init.lua:109) and pairing them avoids two independent per-minute write waves.

5e. The Fatal-then-changelevel paths — Drop

Two exist. DetectDisconnect (FRP sv_init.lua:101-112) is the remote kill-switch, ruled Out of scope on the map — note its own // TODO: Make sure this detects no session row at :100, i.e. the author knew a missing row reads as "not closed" and never fixed it.

The other is local: the heartbeat's if self.token == nil then Fatal(…); changelevel end (FRP sv_init.lua:86-89). Restarting a live server because a token failed to generate inverts the severity — the token generation in §4 cannot fail, and if it somehow did, serving IsReady() == false costs nothing while a changelevel under load costs every player their session. Fail closed, do not fail loud. cityrp.logging does have Critical and Fatal levels (gamemode/core/libraries/sh_logging.lua:616-627) — Critical and a permanent not-ready state is the right response.

(Checked and not a bug: self.logger at FRP sv_init.lua:87 and :107 looks like a typo against the file's own local logger at :16, but PLUGIN:Logger() caches onto self.logger — FRP gamemode/core/libraries/sh_plugin.lua:84-92 — so both resolve. Inconsistent style, not a defect.)

5f. Error handling on failed inserts — Adapt. This is the sharpest edge for CityRP2.

FearlessRP's :Callback() fires on success. CityRP2's does not have that property:

-- gamemode/db.lua:135-157 (abridged)
self.Connection:Query(query, function(res)
    local r = res[1]
    local status, err = r.status, r.error
    if not status or err ~= nil then
        dbLog("Error", "[DB] Error Executing.\n\tError: %s\n\tQuery: %s", err, query)
        if not self:IsConnected() then self:Cache(query, cb); self:Connect() end
    end
    if isfunction(cb) then
        ...
        if query:find("INSERT INTO") then cb(r.lastid) else cb(r.data) end
    end
end)

DB:Run logs the error and then calls the callback anyway — there is no return between the error branch and the dispatch. A failed INSERT INTO server_sessions still invokes the callback, with r.lastid whatever the driver left there. A cityrp.session built on DB:Run would mark itself ready off a failed insert, which is the precise inverse of the map's fail-closed premise and would let refund_ents write track rows against a session_id that does not exist — "permanently unclaimable player property", in the map's words.

DB:RunChecked (gamemode/db.lua:165-196) is the correct primitive: it returns in the error branch and hands the callback cb(ok, result, err). It exists specifically for this — its own doc comment at :161-162 says "Unlike DB:Run, the callback is not invoked with a successful payload when MySQL reports failure — callers must handle ok == false (e.g. migrations)."

Recommendation for #3437: cityrp.session uses DB:RunChecked for its own session INSERT and for the player_sessions INSERT, and flips IsReady() only inside ok == true. Everything else (the heartbeat UPDATE, the ShutDown close-out) can stay on DB:Run, since a lost beat or a lost close-out is cosmetic. Also note the query:find("INSERT INTO") sniff at db.lua:150 / :188: the callback argument is lastid for anything containing that substring and rows otherwise, so the migration and the query text must agree on casing (INSERT INTO, capitalised) for cityrp.session to receive its autoincrement rather than an empty row set.

5g. The opaque token as the cross-table join key — Drop

FRP sv_init.lua:122, 127, 178, 189, 226, 243 all join player_sessions to the server session on session_id = self.token — the 64-character string. Every player-session row therefore carries a 64-byte key, indexed, forever, and every lookup is a string comparison. The map's "locally generated token plus the DB row's autoincrement" split exists to avoid exactly this: the token is the boot-time identity used before the DB answers, and the autoincrement is the join key everything else uses. The prior art is the argument for that decision, not against it.

Corollary for #3442: server_sessions.token wants a UNIQUE index (the library looks its own row up by token exactly once, at insert-confirm time) and can be CHAR(32) given §4's recommendation, not VARCHAR(64).

5h. Timestamp representation — flagging for #3442

FearlessRP uses :RawInsert("opened_at", "NOW()") throughout (FRP sv_init.lua:65-66, 120, 128, 190, 224, 235, 241) — DB-server clock, DATETIME. CityRP2's existing session-adjacent code uses Lua integer epochs: refund_ents' start_time is os.time() (refund_ents/sv_init.lua:21), and onlinelog writes connect/disconnect as os.time() integers (onlinelog/sv_init.lua:93, 105, 112).

Recommendation: DB-side NOW() / CURRENT_TIMESTAMP for the human-facing wall-clock columns (opened_at, closed_at, last_heartbeat_at), because a fleet of servers with drifting clocks otherwise produces sessions that appear to overlap or precede each other. Keep any column a Lua predicate compares against as an integer epoch. That split is #3442's call; both prior arts are represented here so it can be made deliberately rather than by whichever file was copied.

5i. Small things not worth copying

  • //-style comments (FRP sv_init.lua:60, 100). Valid GLua, banned here — AGENTS.md footgun #5.
  • ErrorNoHalt for expected conditions (FRP sv_init.lua:203, 207). Use cityrp.logging at Debug — AGENTS.md footgun #8.
  • The PLUGIN:player_connect2 / PLUGIN:PlayerChangedTeam method-as-hook convention. It is a plugin-loader feature; cityrp.session is a library (gamemode/core/libraries/sv_session.lua per the map) and must use explicit hook.Add with namespaced identifiers. Relatedly, AGENTS.md footgun #18 does not bite: a library is not a plugin and has no realm-resolved dependencies — but any plugin that comes to depend on cityrp.session must guard with cityrp.session.IsReady() rather than a .meta.lua dependency, because the library is server-only.

What this hands the downstream tickets

#3437 — API surface

  • Player-session lifecycle hooks are PlayerInitialized (gamemode/init.lua:1735) and PlayerDisconnected, not player_connect2 / player_disconnect2. No retry, no pendingConnections, no pre-check SELECT (§1).
  • One bot guard: if not IsValid(ply) or ply:IsBot() then return end (§2).
  • Token: util.SHA256 over the load-time entropy mix in §4, truncated to 32 hex characters, derived at library file scope with an x = x or … guard (per #3432).
  • IsReady() flips only inside a DB:RunChecked callback with ok == true (§5f). Everything fails closed until then, including across a DBConnected that never fires (§5c).
  • ShutDown close-out guarded on IsReady() (§5a). SetupHeartbeat guarded on timer.Exists with the timer name held on the library table (§5d).
  • No previous(), no DetectDisconnect, no changelevel anywhere in the library (§5e).
  • sv_hibernate_think observed once at Debug, recorded as a precondition for the kill-switch follow-up rather than enforced here (§3).

#3436 — track-table migration

  • No foreign key from any session table to players. For a first-time player, the session insert is queued ahead of the players insert on the single DB_MAIN connection (sv_player.lua:1715-1725, :1743), and on the build/event servers the players row is never written at all (sv_player.lua:1736-1739). Plain indexed SteamID column, as onlinelog does (§1).
  • Phase two's player_session_jobs child table replaces FearlessRP's close-and-reopen; keep reason meaning only "why the connection ended" (§5b).
  • Query text must spell INSERT INTO capitalised so db.lua:188's substring sniff returns the autoincrement rather than an empty row set (§5f).

#3442 — DB column typing

  • server_sessions.token: CHAR(32), UNIQUE — not VARCHAR(64), and not the cross-table join key (§4, §5g).
  • player_sessions joins on the server session's autoincrement INT/BIGINT, never on the token (§5g).
  • opened_at / closed_at / last_heartbeat_at: DB-side NOW() / CURRENT_TIMESTAMP rather than Lua os.time() integers, so a fleet with clock drift produces a coherent ordering — deliberately diverging from refund_ents and onlinelog, which both use integer epochs (§5h).
  • reason: single-meaning, "why the connection ended". Sized for "Disconnect: " .. <engine reason string> (§5b).

Confidence

Verified from source — every path:line citation above (files read in this worktree at 6580eb0a5); every wiki quotation; the STEAMID_0:0:0 typo; sentry's absence; DB:Run's call-callback-on-error behaviour; the PlayerInitialized-before-players-INSERT ordering; SaveData's build/event early return; hibernate think being forced only by the Discord relay; math.randomseed never being called in gamemode/.

Inferred — that FearlessRP's retry exists to bridge connect-time to row-creation (the log string at FRP sv_init.lua:149 says so, but there is no comment stating the original failure); that the uncancellable-timer and fast-reconnect-overwrite paths in §1 are live bugs rather than handled elsewhere in a file I did not read.

Unverified — whether GMod seeds math.random per process (not documented on the wiki; §4 is written so the answer does not matter); whether -disableluarefresh is set on Limelight boxes (inherited caveat from #3432, and irrelevant here since §5d's guard covers it); the exact server_sessions / player_sessions DDL on FearlessRP, which was not in the two files read.


Sources

Repo (this worktree, branch claude/wayfinder-3431-3433-14e13c at 6580eb0a5)

  • gamemode/init.lua:20-25, 39-56, 100-101, 109-130, 146, 1622-1631, 1701-1760, 1784-1806, 1846, 2393-2402
  • gamemode/db.lua:124-196
  • gamemode/sh_init.lua:5-15
  • gamemode/sentry.lua (whole file, 14 lines)
  • gamemode/core/libraries/sv_gatekeeper.lua:12-13, 57-99
  • gamemode/core/libraries/sv_player.lua:444-495
  • gamemode/core/libraries/sv_logs.lua:188-221
  • gamemode/core/libraries/sv_team.lua:97, 167, 173, 179
  • gamemode/core/libraries/sh_logging.lua:605-637
  • gamemode/core/libraries/sh_access.lua:125, sv_access.lua:110
  • gamemode/core/metatables/sv_player.lua:1548-1752
  • gamemode/core/plugins/onlinelog/sv_init.lua (whole file), cl_init.lua, sh_init.lua
  • gamemode/core/plugins/refund_ents/sv_init.lua:18-34, 233-249
  • gamemode/core/plugins/discord_relay_free/sv_init.lua:61, 352, 405-421
  • gamemode/core/plugins/itemgrantmenu/sv_init.lua:66-70, weapons_on_back/sv_init.lua:212
  • gamemode/core/migrations/ (directory listing)
  • AGENTS.md footguns #2, #5, #7, #8, #11, #18, #19, #22
  • docs/design/server-sessions/research-gm-initialize-boot-predicate.md

FearlessRP prior art (E:\FearlessRP\CityRP)

  • plugins/sessions/sh_plugin.lua (whole file)
  • plugins/sessions/sv_init.lua (whole file)
  • gamemode/core/libraries/sh_plugin.lua:84-92

Garry's Mod Wiki