Research: FearlessRP prior art
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#3433
- 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.
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_disconnect2findspendingConnections[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"eachErrorNoHaltand return.
The literals, from primary source
gameevent/player_connect documents
networkid verbatim as:
The SteamID the player has. Will be
BOTfor bots,STEAM_0:0:0orSTEAM_ID_PENDINGin single-player, andSTEAM_ID_LANwhen using the-multirunclient 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_connect2at all.sv_gatekeeper.lua:58-60returns early fordata.networkid == "BOT"before thehook.Callat:76. The disconnect side is gated on theconnected[userid]flag (:75,:86-89), which was never set for that bot — soplayer_disconnect2does not fire for bots either. FearlessRP's disconnect-sideErrorNoHaltfor 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 atsh_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:70andgamemode/init.lua:1846, among others. Bots do reachPlayerInitialized, 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_thinkis set to1ordelayis set to0).
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, notWarning/Error. There is nocityrpserver["Environment"]key in this repo (LLServeris 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
GetConVarhandle at file scope — the relay sets it fromInitPostEntity, 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 1to be a declared, server-config-level guarantee before any code readsclosed_atback — 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.randomis an ingredient, never the mechanism. If it turns out unseeded, the hash is still distinct becauseLLServer, hostname,SysTime,os.clockand the table address are. Never callmath.randomseedfrom 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_sessionsrow — 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
ShutDownnever checksself.token ~= nil. With a query builder that produces a brokenWHERE; with CityRP2's raw string concatenation it is a "attempt to concatenate a nil value" hard error insideShutDown, which would takesv_logs.lua:188andGM: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 NULLis 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:
- 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. - No dedupe, no rate limit. CityRP2 fires
PlayerChangedTeamfromsv_team.lua:97and: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". - 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 wrongopened_at. - AGENTS.md footgun #2. The callback closes over
sid64and 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 atShutDown.
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.ErrorNoHaltfor expected conditions (FRP sv_init.lua:203, 207). Usecityrp.loggingatDebug— AGENTS.md footgun #8.- The
PLUGIN:player_connect2/PLUGIN:PlayerChangedTeammethod-as-hook convention. It is a plugin-loader feature;cityrp.sessionis a library (gamemode/core/libraries/sv_session.luaper the map) and must use explicithook.Addwith 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 oncityrp.sessionmust guard withcityrp.session.IsReady()rather than a.meta.luadependency, 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) andPlayerDisconnected, notplayer_connect2/player_disconnect2. No retry, nopendingConnections, no pre-check SELECT (§1). - One bot guard:
if not IsValid(ply) or ply:IsBot() then return end(§2). - Token:
util.SHA256over the load-time entropy mix in §4, truncated to 32 hex characters, derived at library file scope with anx = x or …guard (per #3432). IsReady()flips only inside aDB:RunCheckedcallback withok == true(§5f). Everything fails closed until then, including across aDBConnectedthat never fires (§5c).ShutDownclose-out guarded onIsReady()(§5a).SetupHeartbeatguarded ontimer.Existswith the timer name held on the library table (§5d).- No
previous(), noDetectDisconnect, nochangelevelanywhere in the library (§5e). sv_hibernate_thinkobserved once atDebug, 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 theplayersinsert on the singleDB_MAINconnection (sv_player.lua:1715-1725,:1743), and on the build/event servers theplayersrow is never written at all (sv_player.lua:1736-1739). Plain indexed SteamID column, asonlinelogdoes (§1). - Phase two's
player_session_jobschild table replaces FearlessRP's close-and-reopen; keepreasonmeaning only "why the connection ended" (§5b). - Query text must spell
INSERT INTOcapitalised sodb.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— notVARCHAR(64), and not the cross-table join key (§4, §5g).player_sessionsjoins on the server session's autoincrementINT/BIGINT, never on the token (§5g).opened_at/closed_at/last_heartbeat_at: DB-sideNOW()/CURRENT_TIMESTAMPrather than Luaos.time()integers, so a fleet with clock drift produces a coherent ordering — deliberately diverging fromrefund_entsandonlinelog, 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-2402gamemode/db.lua:124-196gamemode/sh_init.lua:5-15gamemode/sentry.lua(whole file, 14 lines)gamemode/core/libraries/sv_gatekeeper.lua:12-13, 57-99gamemode/core/libraries/sv_player.lua:444-495gamemode/core/libraries/sv_logs.lua:188-221gamemode/core/libraries/sv_team.lua:97, 167, 173, 179gamemode/core/libraries/sh_logging.lua:605-637gamemode/core/libraries/sh_access.lua:125,sv_access.lua:110gamemode/core/metatables/sv_player.lua:1548-1752gamemode/core/plugins/onlinelog/sv_init.lua(whole file),cl_init.lua,sh_init.luagamemode/core/plugins/refund_ents/sv_init.lua:18-34, 233-249gamemode/core/plugins/discord_relay_free/sv_init.lua:61, 352, 405-421gamemode/core/plugins/itemgrantmenu/sv_init.lua:66-70,weapons_on_back/sv_init.lua:212gamemode/core/migrations/(directory listing)AGENTS.mdfootguns #2, #5, #7, #8, #11, #18, #19, #22docs/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
- https://wiki.facepunch.com/gmod/gameevent/player_connect
- https://wiki.facepunch.com/gmod/util.SteamIDTo64
- https://wiki.facepunch.com/gmod/util.SHA256
- https://wiki.facepunch.com/gmod/timer.Create
- https://wiki.facepunch.com/gmod/GM:Tick
- https://wiki.facepunch.com/gmod/GM:ShutDown
- https://wiki.facepunch.com/gmod/math.random
- https://wiki.facepunch.com/gmod/math.randomseed
- https://wiki.facepunch.com/gmod/Global.SysTime
- https://wiki.facepunch.com/gmod/game.GetIPAddress
- https://wiki.facepunch.com/gmod/system.SteamTime