Skip to content

fix: guard Hook_StartupServer against spurious second call (HLTV wakeup / ss_dead)#1314

Open
stephanebruckert wants to merge 2 commits into
roflmuffin:mainfrom
2hours-gg:fix/double-startupserver-crash
Open

fix: guard Hook_StartupServer against spurious second call (HLTV wakeup / ss_dead)#1314
stephanebruckert wants to merge 2 commits into
roflmuffin:mainfrom
2hours-gg:fix/double-startupserver-crash

Conversation

@stephanebruckert
Copy link
Copy Markdown

@stephanebruckert stephanebruckert commented May 15, 2026

Problem

Hook_StartupServer can fire twice for the same session when CS2
internally reloads without deactivating the loop mode. The second call
fires OnLevelEnd → PlayerManager disconnects connected players in CSS
state → stale .NET callbacks → SEGV on the next DispatchConCommand.

Two known triggers:

  • Workshop map load (host_workshop_map) — CS2 goes through an
    internal ss_dead reload to fetch the addon and fires
    Hook_StartupServer a second time.
  • HLTV wakeup — when the first player connects to a hibernating
    server, HLTV starts its own session and triggers a second
    Hook_StartupServer.

Fix

Track whether the call follows a genuine OnLevelShutdown (set in the
OnLevelShutdown hook). OnStartupServer takes a bool levelShutdown:

  • true → fire OnLevelEnd and reset per-map tick state (genuine path)
  • false → reset tick state only, no OnLevelEnd (the spurious cycle)

Tick-state reset is unconditional — OnGameFrame's universal_time
math needs it false on the first frame of every session regardless of
which path started it.

Testing

Verified on CS2 1.41.6.2 across 40+ map transitions: host_workshop_map
cycles, changelevel with tv_broadcast on, HLTV wakeup from
hibernation, MatchZy loaded throughout. No crashes.

Related

Fixes #946 and #1239.

Also resolves the following MatchZy issues (same root cause, no MatchZy changes needed):

Comment thread src/mm_plugin.cpp
globals::entitySystem->RemoveListenerEntity(&globals::entityManager.entityListener);
globals::entitySystem->AddListenerEntity(&globals::entityManager.entityListener);

globals::timerSystem.OnStartupServer();
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this now no longer fire on first startup since the bool is false by default?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It still fires on first startup, but via OnLevelInit rather than Hook_StartupServer, so the flag being false by default is intentional. Hook_StartupServer only triggers after the initial load (on genuine changelevel or the spurious HLTV/ss_dead calls this fix guards against).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updating this since the code has changed. While testing the original gated version on a live server, I hit a mid-match MatchZy failure. Tracing it, I found the if (s_bLevelShutdownOccurred) { OnStartupServer(); } gate was also skipping the m_has_map_ticked reset, which broke universal_time math in OnGameFrame and made MatchZy's AutoStart timer fire late mid-match.

So the shape is different now. OnStartupServer(bool levelShutdown) gets called every time, and the bool just controls whether OnLevelEnd fires. On first startup the bool is false, so OnLevelEnd doesn't fire (which was your concern). The function still runs, but the only thing it does is reset m_has_map_ticked / m_has_map_simulated to false, and they're already false at that point, so it's a no-op in practice.

@stephanebruckert
Copy link
Copy Markdown
Author

Hey @roflmuffin I'm investigating a regression, please consider this PR as draft for the time being. This needs much more testing

stephanebruckert added a commit to 2hours-gg/CounterStrikeSharp that referenced this pull request May 19, 2026
Hook_StartupServer fires twice per workshop addon change: once via a
genuine OnLevelShutdown -> OnStartupServer sequence, and again during
the internal ss_dead reload cycle WITHOUT a preceding OnLevelShutdown.

The previous gating (PR roflmuffin#1314) suppressed the entire second call to
TimerSystem::OnStartupServer to avoid the PlayerManager-disconnect ->
stale .NET callbacks -> SEGV chain. But that also skipped the
m_has_map_ticked/m_has_map_simulated reset. On the first frame of the
reloaded session, OnGameFrame computed universal_time delta against an
unrelated last_ticked_time, producing a large positive jump. Per-map
one-off timers (notably MatchZy's 1-second AddTimer(AutoStart)) then
fired arbitrarily late mid-match -- reproducibly aborting live matches.

Fix: OnStartupServer now takes a `levelShutdown` bool. The OnLevelEnd
fan-out (and its disconnect side-effects) stays gated on it, but the
tick-state reset is unconditional. mm_plugin tracks the genuine
LevelShutdown via s_bLevelShutdownOccurred and passes it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stephanebruckert added a commit to 2hours-gg/CounterStrikeSharp that referenced this pull request May 19, 2026
Hook_StartupServer fires twice per workshop addon change: once via a
genuine OnLevelShutdown -> OnStartupServer sequence, and again during
the internal ss_dead reload cycle WITHOUT a preceding OnLevelShutdown.

The previous gating (PR roflmuffin#1314) suppressed the entire second call to
TimerSystem::OnStartupServer to avoid the PlayerManager-disconnect ->
stale .NET callbacks -> SEGV chain. But that also skipped the
m_has_map_ticked/m_has_map_simulated reset. On the first frame of the
reloaded session, OnGameFrame computed universal_time delta against an
unrelated last_ticked_time, producing a large positive jump. Per-map
one-off timers (notably MatchZy's 1-second AddTimer(AutoStart)) then
fired arbitrarily late mid-match -- reproducibly aborting live matches.

Fix: OnStartupServer now takes a `levelShutdown` bool. The OnLevelEnd
fan-out (and its disconnect side-effects) stays gated on it, but the
tick-state reset is unconditional. mm_plugin tracks the genuine
LevelShutdown via s_bLevelShutdownOccurred and passes it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
stephanebruckert and others added 2 commits May 19, 2026 21:45
… server

Servers running with HLTV/tv_broadcast crash when waking from hibernation:
the first player to connect triggers an HLTV network startup that fires a
second StartupServer call, causing CSS to synthetic-disconnect all tracked
players → stale .NET callbacks → SEGV. This also manifests as a crash on
map change when HLTV is active (reported as MatchZy roflmuffin#380).

INetworkServerService::StartupServer fires more than once per session:
once on genuine changelevel, and again when HLTV starts its own network
session as the first player wakes a hibernating server (ss_dead cycle).
The second call triggered OnStartupServer() → OnLevelEnd() →
PlayerManager synthetic-disconnects all CSS-tracked players → stale
.NET callbacks → SEGV on the next DispatchConCommand hook.

Fix: introduce s_bLevelShutdownOccurred, set by OnLevelShutdown() (which
fires only on genuine ILoopMode::LoopShutdown, never during ss_dead).
Hook_StartupServer now calls OnStartupServer() only when that flag is
set, ignoring the spurious second invocation.

Also call RemoveListenerEntity before AddListenerEntity to prevent
double-registration on the same entity listener during the ss_dead cycle.

Reproducer: server in hibernation, first player connects → HLTV wakeup
fires second StartupServer → crash. Confirmed fixed empirically.

Fixes roflmuffin#946. Also resolves MatchZy issue roflmuffin/MatchZy#380.
Hook_StartupServer fires twice per workshop addon change: once via a
genuine OnLevelShutdown -> OnStartupServer sequence, and again during
the internal ss_dead reload cycle WITHOUT a preceding OnLevelShutdown.

The previous gating (PR roflmuffin#1314) suppressed the entire second call to
TimerSystem::OnStartupServer to avoid the PlayerManager-disconnect ->
stale .NET callbacks -> SEGV chain. But that also skipped the
m_has_map_ticked/m_has_map_simulated reset. On the first frame of the
reloaded session, OnGameFrame computed universal_time delta against an
unrelated last_ticked_time, producing a large positive jump. Per-map
one-off timers (notably MatchZy's 1-second AddTimer(AutoStart)) then
fired arbitrarily late mid-match -- reproducibly aborting live matches.

Fix: OnStartupServer now takes a `levelShutdown` bool. The OnLevelEnd
fan-out (and its disconnect side-effects) stays gated on it, but the
tick-state reset is unconditional. mm_plugin tracks the genuine
LevelShutdown via s_bLevelShutdownOccurred and passes it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@stephanebruckert stephanebruckert force-pushed the fix/double-startupserver-crash branch from 852bb78 to 202d0ee Compare May 19, 2026 20:46
@stephanebruckert
Copy link
Copy Markdown
Author

Investigation is done, this is ready for review again. Details on the code shape change are in the inline thread above, full bug and fix story in the PR description.

Validated on CS2 1.41.6.2 across 40+ map transitions: host_workshop_map cycles, HLTV wakeup from hibernation, changelevel with tv_broadcast on, MatchZy loaded throughout. No crashes.

If anyone wants to try the fix on their own server before merge, prebuilt binaries are at https://github.com/2hours-gg/CounterStrikeSharp/releases/tag/v1.0.368-2hours.2.

I also have unit tests for the OnStartupServer(bool) contract on a sibling branch (diff). Happy to either fold them into this PR or open as a follow-up, whichever you'd prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server crashes with tv_broadcast 1 set and map change

2 participants