From a4e7fc8011b8931da6b22c8aafe3d8a87d420b44 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 13:16:36 -0400 Subject: [PATCH 1/9] bug: keep repeating timers running after a callback throws --- .../src/FiveStack.Utilities/TimerUtility.cs | 17 +++++++++++++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 20 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs b/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs index 0b9fee13..b5178a77 100644 --- a/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs @@ -1,4 +1,5 @@ using System.Threading; +using Microsoft.Extensions.Logging; using SwiftlyS2.Shared; namespace FiveStack.Utilities @@ -24,7 +25,21 @@ public static CancellationTokenSource AddTimer(float interval, Action callback) public static CancellationTokenSource Repeat(float interval, Action callback) { - CancellationTokenSource timer = _core.Scheduler.RepeatBySeconds(interval, callback); + // SwiftlyS2 never reschedules a repeating timer whose callback throws + CancellationTokenSource timer = _core.Scheduler.RepeatBySeconds( + interval, + () => + { + try + { + callback(); + } + catch (Exception ex) + { + _core.Logger.LogError(ex, "repeating timer callback failed"); + } + } + ); Timers.Add(timer); return timer; } diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index 73fc5984..c8db223a 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -267,8 +267,8 @@ public override void Load(bool hotReload) // One repeating job for the whole plugin, not one per player. These // deliberately do not get StopOnMapChange: the plugin is not reloaded // on a map change, so a timer that stopped there would never come back. - _secondTimer = Core.Scheduler.RepeatBySeconds(1, OnSecond); - _refillTimer = Core.Scheduler.RepeatBySeconds(0.1f, OnFastTick); + _secondTimer = Core.Scheduler.RepeatBySeconds(1, Surviving(OnSecond)); + _refillTimer = Core.Scheduler.RepeatBySeconds(0.1f, Surviving(OnFastTick)); // Only on a hot reload. A cold boot has no engine globals yet -- asking // for the map here is what stopped the plugin loading at all -- and the @@ -300,6 +300,22 @@ public override void Load(bool hotReload) ); } + // SwiftlyS2 never reschedules a repeating timer whose callback throws. + private Action Surviving(Action callback) + { + return () => + { + try + { + callback(); + } + catch (Exception error) + { + _logger.LogError(error, "repeating timer callback failed"); + } + }; + } + // SwiftlyS2 hot reloads a plugin from a thread pool thread -- its file // watcher schedules the reload through Task.Run -- and the game thread is // never one of those. Every native call throws off the game thread, so the From d7d9e26112a8aad249a822f01450bd9c56cbe11d Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 13:17:11 -0400 Subject: [PATCH 2/9] feature: keep pinging the panel while the server hibernates --- .../src/FiveStack.Services/GameServer.cs | 2 +- apps/swiftly/src/FiveStack.cs | 28 +++++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/swiftly/src/FiveStack.Services/GameServer.cs b/apps/swiftly/src/FiveStack.Services/GameServer.cs index c8084781..698ae608 100644 --- a/apps/swiftly/src/FiveStack.Services/GameServer.cs +++ b/apps/swiftly/src/FiveStack.Services/GameServer.cs @@ -108,7 +108,7 @@ public void Ping(string pluginVersion) string? serverId = _environmentService.GetServerId(); string? apiPassword = _environmentService.GetServerApiPassword(); - _core.Scheduler.NextTick(() => + _core.Scheduler.NextWorldUpdate(() => { if (serverId == null || apiPassword == null) { diff --git a/apps/swiftly/src/FiveStack.cs b/apps/swiftly/src/FiveStack.cs index 75098f4a..66744d41 100644 --- a/apps/swiftly/src/FiveStack.cs +++ b/apps/swiftly/src/FiveStack.cs @@ -40,7 +40,9 @@ public partial class FiveStackPlugin : BasePlugin private GameBackUpRounds _gameBackupRounds = null!; private EnvironmentService _environmentService = null!; - private CancellationTokenSource? _pingTimer; + private const long PingEveryMs = 15_000; + private EventDelegates.OnWorldUpdate? _pingHandler; + private long _lastPingMs; private Guid _chatHookId; private Guid _commandHookId; private EventDelegates.OnPrecacheResource? _precacheHandler; @@ -192,8 +194,25 @@ public override void Load(bool hotReload) else { _gameServer.Ping(ModuleVersion); - _pingTimer = TimerUtility.Repeat(15, () => _gameServer.Ping(ModuleVersion)); + _lastPingMs = Environment.TickCount64; + + // OnWorldUpdate keeps firing while the server hibernates; ticks and timers stop + _pingHandler = PingOnWorldUpdate; + Core.Event.OnWorldUpdate += _pingHandler; + } + } + + private void PingOnWorldUpdate() + { + long nowMs = Environment.TickCount64; + + if (nowMs - _lastPingMs < PingEveryMs) + { + return; } + + _lastPingMs = nowMs; + _gameServer.Ping(ModuleVersion); } public override void Unload() @@ -226,7 +245,10 @@ public override void Unload() UninstallConnectClientHook(); ClanTagUtility.Unhook(); - TimerUtility.Kill(_pingTimer); + if (_pingHandler != null) + { + Core.Event.OnWorldUpdate -= _pingHandler; + } _matchService.GetCurrentMatch()?.Reset(); From a5731dc11ea5f47f76fac45bbc16cea0cd80072c Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 13:30:11 -0400 Subject: [PATCH 3/9] feature: keep the CounterStrikeSharp ping going through hibernation --- apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs index 860ae864..0a2ad621 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs @@ -88,7 +88,7 @@ public void Ping(string pluginVersion) string? serverId = _environmentService.GetServerId(); string? apiPassword = _environmentService.GetServerApiPassword(); - Server.NextFrame(async () => + Server.NextWorldUpdate(async () => { if (serverId == null || apiPassword == null) { From 3ecb023d8b69fa62c131ed05543a3f24ff08225f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 13:37:37 -0400 Subject: [PATCH 4/9] feature: set a match up while the server hibernates, before anyone connects --- .../src/FiveStack.Events/MapChange.cs | 3 +- .../src/FiveStack.Services/GameServer.cs | 2 +- .../src/FiveStack.Services/MatchManager.cs | 6 +-- .../src/FiveStack.Services/MatchService.cs | 4 +- .../FiveStack.Utilities/HibernationUtility.cs | 29 ++++++++++++ .../counterstrikesharp/src/FiveStackPlugin.cs | 1 + .../swiftly/src/FiveStack.Events/MapChange.cs | 2 +- .../src/FiveStack.Services/GameServer.cs | 2 +- .../src/FiveStack.Services/MatchManager.cs | 6 +-- .../src/FiveStack.Services/MatchService.cs | 4 +- .../FiveStack.Utilities/HibernationUtility.cs | 45 +++++++++++++++++++ apps/swiftly/src/FiveStack.cs | 2 + 12 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs create mode 100644 apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs diff --git a/apps/counterstrikesharp/src/FiveStack.Events/MapChange.cs b/apps/counterstrikesharp/src/FiveStack.Events/MapChange.cs index 8dbf784b..825939e1 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/MapChange.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/MapChange.cs @@ -1,6 +1,7 @@ using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using FiveStack.Enums; +using FiveStack.Utilities; using Microsoft.Extensions.Logging; namespace FiveStack; @@ -19,7 +20,7 @@ public void ListenForMapChange() // code smell: we have to wait till server exec's default cfgs await Task.Delay(1000 * 5); - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { MatchManager? match = _matchService.GetCurrentMatch(); match?.SyncActiveMapAfterMapStart(); diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs index 0a2ad621..29d597ef 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs @@ -33,7 +33,7 @@ MatchService matchService public void SendCommands(string[] commands) { - Server.NextFrame(() => Server.ExecuteCommand(string.Join(";", commands))); + HibernationUtility.NextFrame(() => Server.ExecuteCommand(string.Join(";", commands))); } public void Message( diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs index ab088c02..1b231454 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -613,7 +613,7 @@ public void SetupMatch(MatchData match) ApplyWorkshopBlockedCvars(); } - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { if (!wasAlreadySetup) { @@ -866,7 +866,7 @@ private void StartWarmup() return; } - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { bool isInWarmup = MatchUtility.Rules()?.WarmupPeriod ?? false; @@ -1581,7 +1581,7 @@ public void SetupBroadcast() .Find("tv_broadcast_origin_auth") ?.SetValue($"{_matchData.id}:{_matchData.password}"); - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { _gameServer.SendCommands(["tv_broadcast 1"]); _logger.LogInformation("Sending TV Broadcast command"); diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs index afd39bd1..274ec127 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs @@ -89,7 +89,7 @@ public async void GetMatchFromApi() if (serverId == null || apiPassword == null) { await Task.Delay(1000 * 5); - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { GetMatchFromApi(); }); @@ -113,7 +113,7 @@ public async void GetMatchFromApi() httpResponse.EnsureSuccessStatusCode(); string? response = await httpResponse.Content.ReadAsStringAsync(); - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { Guid? previousMatchId = _currentMatch?.GetMatchData()?.id; diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs new file mode 100644 index 00000000..5113b09b --- /dev/null +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs @@ -0,0 +1,29 @@ +using CounterStrikeSharp.API; + +namespace FiveStack.Utilities; + +// A hibernating server stops running frames but keeps updating the world, and +// it only wakes once somebody is let in. Whatever has to be in place before +// that (the match, its password, the map) is queued through here: on the next +// frame while the server is awake, exactly as before, and on the next world +// update while it hibernates. +public static class HibernationUtility +{ + private static volatile bool _hibernating; + + public static void SetHibernating(bool hibernating) + { + _hibernating = hibernating; + } + + public static void NextFrame(Action callback) + { + if (_hibernating) + { + Server.NextWorldUpdate(callback); + return; + } + + Server.NextFrame(callback); + } +} diff --git a/apps/counterstrikesharp/src/FiveStackPlugin.cs b/apps/counterstrikesharp/src/FiveStackPlugin.cs index 63e4856b..745abd87 100644 --- a/apps/counterstrikesharp/src/FiveStackPlugin.cs +++ b/apps/counterstrikesharp/src/FiveStackPlugin.cs @@ -78,6 +78,7 @@ public override void Load(bool hotReload) ); RegisterListener(_rankSystem.OnTick); + RegisterListener(HibernationUtility.SetHibernating); TimerUtility.AddTimer( RankSystem.RosterRefreshInterval, diff --git a/apps/swiftly/src/FiveStack.Events/MapChange.cs b/apps/swiftly/src/FiveStack.Events/MapChange.cs index 633df67c..a54e157b 100644 --- a/apps/swiftly/src/FiveStack.Events/MapChange.cs +++ b/apps/swiftly/src/FiveStack.Events/MapChange.cs @@ -12,7 +12,7 @@ public async void OnMapLoad(IOnMapLoadEvent @event) _logger.LogInformation("map changed: precaching models"); await Task.Delay(1000 * 5); - _core.Scheduler.NextTick(() => + FiveStack.Utilities.HibernationUtility.NextTick(() => { MatchManager? match = _matchService.GetCurrentMatch(); match?.SyncActiveMapAfterMapStart(); diff --git a/apps/swiftly/src/FiveStack.Services/GameServer.cs b/apps/swiftly/src/FiveStack.Services/GameServer.cs index 698ae608..bce2f6c8 100644 --- a/apps/swiftly/src/FiveStack.Services/GameServer.cs +++ b/apps/swiftly/src/FiveStack.Services/GameServer.cs @@ -44,7 +44,7 @@ public void SendCommands(string[] commands) ); } - _core.Scheduler.NextTick(() => _core.Engine.ExecuteCommand(string.Join(";", commands))); + HibernationUtility.NextTick(() => _core.Engine.ExecuteCommand(string.Join(";", commands))); } public void Message( diff --git a/apps/swiftly/src/FiveStack.Services/MatchManager.cs b/apps/swiftly/src/FiveStack.Services/MatchManager.cs index 9b79f79f..c4b9cd45 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -599,7 +599,7 @@ public void SetupMatch(MatchData match) ApplyWorkshopBlockedCvars(); } - _core.Scheduler.NextTick(() => + HibernationUtility.NextTick(() => { if (!wasAlreadySetup) { @@ -859,7 +859,7 @@ private void StartWarmup() return; } - _core.Scheduler.NextTick(() => + HibernationUtility.NextTick(() => { bool isInWarmup = MatchUtility.Rules()?.WarmupPeriod ?? false; @@ -1572,7 +1572,7 @@ public void SetupBroadcast() SetConVar("tv_broadcast_url", $"{_environmentService.GetRelayUrl()}/{_matchData.id}"); SetConVar("tv_broadcast_origin_auth", $"{_matchData.id}:{_matchData.password}"); - _core.Scheduler.NextTick(() => + HibernationUtility.NextTick(() => { _gameServer.SendCommands(["tv_broadcast 1"]); _logger.LogInformation("Sending TV Broadcast command"); diff --git a/apps/swiftly/src/FiveStack.Services/MatchService.cs b/apps/swiftly/src/FiveStack.Services/MatchService.cs index a7bbe26c..7e9f313c 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchService.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchService.cs @@ -89,7 +89,7 @@ public async void GetMatchFromApi() if (serverId == null || apiPassword == null) { await Task.Delay(1000 * 5); - _core.Scheduler.NextTick(() => + HibernationUtility.NextTick(() => { GetMatchFromApi(); }); @@ -113,7 +113,7 @@ public async void GetMatchFromApi() httpResponse.EnsureSuccessStatusCode(); string? response = await httpResponse.Content.ReadAsStringAsync(); - _core.Scheduler.NextTick(() => + HibernationUtility.NextTick(() => { Guid? previousMatchId = _currentMatch?.GetMatchData()?.id; diff --git a/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs new file mode 100644 index 00000000..04f754b7 --- /dev/null +++ b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs @@ -0,0 +1,45 @@ +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Events; + +namespace FiveStack.Utilities; + +// A hibernating server stops ticking but keeps updating the world, and it only +// wakes once somebody is let in. Whatever has to be in place before that (the +// match, its password, the map) is queued through here: on the next tick while +// the server is ticking, exactly as before, and on the next world update when +// it is not. +public static class HibernationUtility +{ + private const long TickingWindowMs = 250; + + private static ISwiftlyCore _core = null!; + private static EventDelegates.OnTick? _tickHandler; + private static long _lastTickMs; + + public static void Initialize(ISwiftlyCore core) + { + _core = core; + _tickHandler = () => Volatile.Write(ref _lastTickMs, Environment.TickCount64); + _core.Event.OnTick += _tickHandler; + } + + public static void Shutdown() + { + if (_tickHandler != null) + { + _core.Event.OnTick -= _tickHandler; + _tickHandler = null; + } + } + + public static void NextTick(Action callback) + { + if (Environment.TickCount64 - Volatile.Read(ref _lastTickMs) < TickingWindowMs) + { + _core.Scheduler.NextTick(callback); + return; + } + + _core.Scheduler.NextWorldUpdate(callback); + } +} diff --git a/apps/swiftly/src/FiveStack.cs b/apps/swiftly/src/FiveStack.cs index 66744d41..6b5709d0 100644 --- a/apps/swiftly/src/FiveStack.cs +++ b/apps/swiftly/src/FiveStack.cs @@ -101,6 +101,7 @@ public override void Load(bool hotReload) MatchUtility.Initialize(Core); TimerUtility.Initialize(Core); + HibernationUtility.Initialize(Core); _environmentService = _serviceProvider.GetRequiredService(); _steamService = _serviceProvider.GetRequiredService(); @@ -255,5 +256,6 @@ public override void Unload() _ = _matchEvents.Disconnect(); TimerUtility.ClearAll(); + HibernationUtility.Shutdown(); } } From c368a8a3da248a1c1302ceeaf857c730b82ef821 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 13:49:07 -0400 Subject: [PATCH 5/9] bug: always clear timers on unload, and never run a killed one --- .../src/FiveStack.Utilities/TimerUtility.cs | 12 ++++++++-- apps/swiftly/src/FiveStack.cs | 21 ++++++++++++---- apps/utility-sw/src/UtilityPracticePlugin.cs | 24 +++++++++++++++++-- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs b/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs index b5178a77..00287d2d 100644 --- a/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/TimerUtility.cs @@ -25,11 +25,19 @@ public static CancellationTokenSource AddTimer(float interval, Action callback) public static CancellationTokenSource Repeat(float interval, Action callback) { - // SwiftlyS2 never reschedules a repeating timer whose callback throws - CancellationTokenSource timer = _core.Scheduler.RepeatBySeconds( + CancellationTokenSource? timer = null; + + // SwiftlyS2 never reschedules a repeating timer whose callback throws, + // and still runs a timer's first pass if it was cancelled before then + timer = _core.Scheduler.RepeatBySeconds( interval, () => { + if (timer?.IsCancellationRequested == true) + { + return; + } + try { callback(); diff --git a/apps/swiftly/src/FiveStack.cs b/apps/swiftly/src/FiveStack.cs index 75098f4a..00fde5a8 100644 --- a/apps/swiftly/src/FiveStack.cs +++ b/apps/swiftly/src/FiveStack.cs @@ -224,14 +224,27 @@ public override void Unload() } UninstallConnectClientHook(); - ClanTagUtility.Unhook(); TimerUtility.Kill(_pingTimer); - _matchService.GetCurrentMatch()?.Reset(); + // SwiftlyS2 only tears a plugin down once Unload returns, and a hot + // reload loads the new build either way: an exception here would leave + // this instance's timers and handlers running beside the new one. + try + { + ClanTagUtility.Unhook(); - _ = _matchEvents.Disconnect(); + _matchService.GetCurrentMatch()?.Reset(); - TimerUtility.ClearAll(); + _ = _matchEvents.Disconnect(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to reset match state during unload"); + } + finally + { + TimerUtility.ClearAll(); + } } } diff --git a/apps/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index c8db223a..aa1a0ade 100644 --- a/apps/utility-sw/src/UtilityPracticePlugin.cs +++ b/apps/utility-sw/src/UtilityPracticePlugin.cs @@ -300,11 +300,19 @@ public override void Load(bool hotReload) ); } - // SwiftlyS2 never reschedules a repeating timer whose callback throws. + private bool _tornDown; + + // SwiftlyS2 never reschedules a repeating timer whose callback throws, and + // still runs a timer's first pass if it was cancelled before then. private Action Surviving(Action callback) { return () => { + if (_tornDown) + { + return; + } + try { callback(); @@ -325,7 +333,17 @@ public override void Unload() { if (!Thread.CurrentThread.IsThreadPoolThread) { - TearDown(); + // An exception out of Unload stops SwiftlyS2 tearing this instance + // down, which leaves its timers and handlers running. + try + { + TearDown(); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "teardown failed"); + } + return; } @@ -354,6 +372,8 @@ public override void Unload() private void TearDown() { + _tornDown = true; + // Drawn entities are not the plugin's to leave behind: without this a // hot reload orphans every beam, label and model in the world, with no // instance left holding a reference to any of them. From 9269864dac6d1e787fded8a0c7026baa7a1d6da9 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 14:25:43 -0400 Subject: [PATCH 6/9] feature: opt a server into hibernation with HIBERNATE_WHEN_EMPTY, on for dev servers --- apps/counterstrikesharp/Dockerfile | 1 + apps/counterstrikesharp/scripts/setup.sh | 3 +++ apps/swiftly/Dockerfile | 1 + apps/swiftly/scripts/setup.sh | 3 +++ k8s/dev-game-server.yaml | 2 ++ k8s/dev-swiftly-game-server.yaml | 2 ++ 6 files changed, 12 insertions(+) diff --git a/apps/counterstrikesharp/Dockerfile b/apps/counterstrikesharp/Dockerfile index 81debab3..f50d7dfc 100644 --- a/apps/counterstrikesharp/Dockerfile +++ b/apps/counterstrikesharp/Dockerfile @@ -141,6 +141,7 @@ ENV DEFAULT_MAP="de_inferno" ENV STEAM_RELAY="false" ENV SERVER_TYPE="Ranked" +ENV HIBERNATE_WHEN_EMPTY="false" ENV METAMOD_URL=https://github.com/alliedmodders/metamod-source/releases/download/2.0.0.1473/mmsource-2.0.0-git1473-linux.tar.gz ENV COUNTER_STRIKE_SHARP_URL=https://github.com/roflmuffin/CounterStrikeSharp/releases/download/v1.0.376/counterstrikesharp-with-runtime-linux-1.0.376.zip diff --git a/apps/counterstrikesharp/scripts/setup.sh b/apps/counterstrikesharp/scripts/setup.sh index 954023ee..5b8eff93 100755 --- a/apps/counterstrikesharp/scripts/setup.sh +++ b/apps/counterstrikesharp/scripts/setup.sh @@ -45,6 +45,9 @@ echo "---Create Symbolic Links---" if [ "$SERVER_TYPE" = "Ranked" ]; then cp "/opt/server-cfg/ranked.server.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg/server.cfg" + if [ "$HIBERNATE_WHEN_EMPTY" = "true" ]; then + sed -i 's/^sv_hibernate_when_empty.*/sv_hibernate_when_empty 1/' "$INSTANCE_SERVER_DIR/game/csgo/cfg/server.cfg" + fi cp "/opt/server-cfg/5stack.competitive.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg" cp "/opt/server-cfg/5stack.duel.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg" cp "/opt/server-cfg/5stack.knife.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg" diff --git a/apps/swiftly/Dockerfile b/apps/swiftly/Dockerfile index 6c1ec8e5..2bf71a19 100644 --- a/apps/swiftly/Dockerfile +++ b/apps/swiftly/Dockerfile @@ -131,6 +131,7 @@ ENV DEFAULT_MAP="de_inferno" ENV STEAM_RELAY="false" ENV SERVER_TYPE="Ranked" +ENV HIBERNATE_WHEN_EMPTY="false" ENV SWIFTLYS2_VERSION="v1.4.13" ENV SWIFTLYS2_URL=https://github.com/swiftly-solution/swiftlys2/releases/download/${SWIFTLYS2_VERSION}/swiftlys2-linux-${SWIFTLYS2_VERSION}-with-runtimes.zip diff --git a/apps/swiftly/scripts/setup.sh b/apps/swiftly/scripts/setup.sh index 51d06848..45f816a1 100755 --- a/apps/swiftly/scripts/setup.sh +++ b/apps/swiftly/scripts/setup.sh @@ -50,6 +50,9 @@ echo "---Create Symbolic Links---" if [ "$SERVER_TYPE" = "Ranked" ]; then cp "/opt/server-cfg/ranked.server.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg/server.cfg" + if [ "$HIBERNATE_WHEN_EMPTY" = "true" ]; then + sed -i 's/^sv_hibernate_when_empty.*/sv_hibernate_when_empty 1/' "$INSTANCE_SERVER_DIR/game/csgo/cfg/server.cfg" + fi cp "/opt/server-cfg/5stack.base.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg" cp "/opt/server-cfg/5stack.competitive.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg" cp "/opt/server-cfg/5stack.duel.cfg" "$INSTANCE_SERVER_DIR/game/csgo/cfg" diff --git a/k8s/dev-game-server.yaml b/k8s/dev-game-server.yaml index 5bc12175..5b4fdef9 100644 --- a/k8s/dev-game-server.yaml +++ b/k8s/dev-game-server.yaml @@ -59,6 +59,8 @@ spec: value: '-maxplayers 13 +map de_overpass' - name: ALLOW_BOTS value: 'true' + - name: HIBERNATE_WHEN_EMPTY + value: 'true' - name: STEAM_RELAY value: 'true' volumeMounts: diff --git a/k8s/dev-swiftly-game-server.yaml b/k8s/dev-swiftly-game-server.yaml index b5da69ea..ca8f4d2d 100644 --- a/k8s/dev-swiftly-game-server.yaml +++ b/k8s/dev-swiftly-game-server.yaml @@ -57,6 +57,8 @@ spec: value: '-maxplayers 13 +map de_overpass -insecure' - name: ALLOW_BOTS value: 'true' + - name: HIBERNATE_WHEN_EMPTY + value: 'true' - name: STEAM_RELAY value: 'true' volumeMounts: From 23267d4e0b660c14380da63b2f9940d27f213807 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 21:15:24 -0400 Subject: [PATCH 7/9] feature: take a match from the ping reply while hibernating, and stay awake for it --- .../src/FiveStack.Services/GameServer.cs | 18 +++++++- .../src/FiveStack.Services/MatchService.cs | 46 +++++++++++++++++++ .../FiveStack.Utilities/HibernationUtility.cs | 5 +- apps/swiftly/src/FiveStack.cs | 1 + apps/swiftly/test/PingReplyUtilityTests.cs | 25 ++++++++++ .../FiveStack.Utilities/PingReplyUtility.cs | 29 ++++++++++++ 6 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 apps/swiftly/test/PingReplyUtilityTests.cs create mode 100644 shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs diff --git a/apps/swiftly/src/FiveStack.Services/GameServer.cs b/apps/swiftly/src/FiveStack.Services/GameServer.cs index bce2f6c8..a7bcfe0b 100644 --- a/apps/swiftly/src/FiveStack.Services/GameServer.cs +++ b/apps/swiftly/src/FiveStack.Services/GameServer.cs @@ -122,8 +122,11 @@ public void Ping(string pluginVersion) ? _core.Engine.GlobalVars.MapName.ToString() : workshopID; + string? matchId = _matchService.GetCurrentMatch()?.GetMatchData()?.id.ToString(); + bool hibernating = !HibernationUtility.IsTicking; + string endpoint = - $"{_environmentService.GetApiUrl()}/game-server-node/ping/{serverId}?map={mapName}&pluginVersion={pluginVersion}&pluginRuntime=swiftlys2"; + $"{_environmentService.GetApiUrl()}/game-server-node/ping/{serverId}?map={mapName}&pluginVersion={pluginVersion}&pluginRuntime=swiftlys2&matchId={matchId}&hibernating={(hibernating ? "true" : "false")}"; if (_steamRelay) { @@ -154,6 +157,19 @@ public void Ping(string pluginVersion) cts.Token ); response.EnsureSuccessStatusCode(); + + // Only with nothing loaded: a server that has a match is awake, + // and the panel reaches an awake server over RCON as before. + if ( + matchId == null + && PingReplyUtility.WantsMatch( + await response.Content.ReadAsStringAsync(cts.Token) + ) + ) + { + _logger.LogInformation("the panel has a match for this server"); + _matchService.GetMatchFromApi(); + } } catch (OperationCanceledException) { diff --git a/apps/swiftly/src/FiveStack.Services/MatchService.cs b/apps/swiftly/src/FiveStack.Services/MatchService.cs index 7e9f313c..bf31cc22 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchService.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchService.cs @@ -126,6 +126,8 @@ public async void GetMatchFromApi() _currentMatch = null; + StayAwake(false); + _logger.LogWarning( $"No match assigned — clearing match {previousMatchId?.ToString() ?? "none"}" ); @@ -139,6 +141,8 @@ public async void GetMatchFromApi() return; } + StayAwake(true); + if (previousMatchId == matchData.id) { _logger.LogInformation( @@ -178,6 +182,48 @@ public async void GetMatchFromApi() } } + private bool _heldAwake; + + // A hibernating server runs no ticks and cannot be reached over RCON, so it + // only hibernates while it has nothing to do: it is held awake from the + // moment it is given a match until that match is gone. + public void StayAwake(bool awake) + { + try + { + var hibernate = _core.ConVar.Find("sv_hibernate_when_empty"); + + if (hibernate == null) + { + return; + } + + if (awake) + { + if (hibernate.Value) + { + // SetInternal applies now; a queued set waits for a tick + hibernate.SetInternal(false); + _heldAwake = true; + _logger.LogInformation("holding the server awake for its match"); + } + + return; + } + + if (_heldAwake) + { + hibernate.SetInternal(true); + _heldAwake = false; + _logger.LogInformation("no match left, the server may hibernate again"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "unable to change sv_hibernate_when_empty"); + } + } + public void GetMatchFromOffline() { string? offlineMatchData = _environmentService.GetOfflineMatchData(); diff --git a/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs index 04f754b7..5602295d 100644 --- a/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs @@ -32,9 +32,12 @@ public static void Shutdown() } } + public static bool IsTicking => + Environment.TickCount64 - Volatile.Read(ref _lastTickMs) < TickingWindowMs; + public static void NextTick(Action callback) { - if (Environment.TickCount64 - Volatile.Read(ref _lastTickMs) < TickingWindowMs) + if (IsTicking) { _core.Scheduler.NextTick(callback); return; diff --git a/apps/swiftly/src/FiveStack.cs b/apps/swiftly/src/FiveStack.cs index 8a5050d8..2804be1c 100644 --- a/apps/swiftly/src/FiveStack.cs +++ b/apps/swiftly/src/FiveStack.cs @@ -258,6 +258,7 @@ public override void Unload() ClanTagUtility.Unhook(); _matchService.GetCurrentMatch()?.Reset(); + _matchService.StayAwake(false); _ = _matchEvents.Disconnect(); } diff --git a/apps/swiftly/test/PingReplyUtilityTests.cs b/apps/swiftly/test/PingReplyUtilityTests.cs new file mode 100644 index 00000000..6f9c9b06 --- /dev/null +++ b/apps/swiftly/test/PingReplyUtilityTests.cs @@ -0,0 +1,25 @@ +using FiveStack.Utilities; +using Xunit; + +public class PingReplyUtilityTests +{ + [Fact] + public void AMatchIsFetchedOnlyWhenThePanelAsksForIt() + { + Assert.True(PingReplyUtility.WantsMatch("""{"get_match":true}""")); + Assert.False(PingReplyUtility.WantsMatch("""{"get_match":false}""")); + } + + // Panels older than this reply with nothing at all. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("{}")] + [InlineData("[]")] + [InlineData("OK")] + [InlineData("""{"get_match":"true"}""")] + public void AnythingElseIsNotARequest(string? body) + { + Assert.False(PingReplyUtility.WantsMatch(body)); + } +} diff --git a/shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs b/shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs new file mode 100644 index 00000000..2307b6f2 --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace FiveStack.Utilities; + +// A hibernating server does not answer RCON, so the panel cannot push +// `get_match` to it. It answers the server's own ping instead. +public static class PingReplyUtility +{ + public static bool WantsMatch(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return false; + } + + try + { + using JsonDocument reply = JsonDocument.Parse(body); + + return reply.RootElement.ValueKind == JsonValueKind.Object + && reply.RootElement.TryGetProperty("get_match", out JsonElement wants) + && wants.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } +} From b2480c5fb29e0495c84c174c6d8de07fbc27d3b7 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 21:36:29 -0400 Subject: [PATCH 8/9] feature: drop the ping reply, RCON reaches a hibernating server after all --- .../src/FiveStack.Services/GameServer.cs | 18 +----------- .../src/FiveStack.Services/MatchService.cs | 7 +++-- .../FiveStack.Utilities/HibernationUtility.cs | 5 +--- apps/swiftly/test/PingReplyUtilityTests.cs | 25 ---------------- .../FiveStack.Utilities/PingReplyUtility.cs | 29 ------------------- 5 files changed, 6 insertions(+), 78 deletions(-) delete mode 100644 apps/swiftly/test/PingReplyUtilityTests.cs delete mode 100644 shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs diff --git a/apps/swiftly/src/FiveStack.Services/GameServer.cs b/apps/swiftly/src/FiveStack.Services/GameServer.cs index a7bcfe0b..bce2f6c8 100644 --- a/apps/swiftly/src/FiveStack.Services/GameServer.cs +++ b/apps/swiftly/src/FiveStack.Services/GameServer.cs @@ -122,11 +122,8 @@ public void Ping(string pluginVersion) ? _core.Engine.GlobalVars.MapName.ToString() : workshopID; - string? matchId = _matchService.GetCurrentMatch()?.GetMatchData()?.id.ToString(); - bool hibernating = !HibernationUtility.IsTicking; - string endpoint = - $"{_environmentService.GetApiUrl()}/game-server-node/ping/{serverId}?map={mapName}&pluginVersion={pluginVersion}&pluginRuntime=swiftlys2&matchId={matchId}&hibernating={(hibernating ? "true" : "false")}"; + $"{_environmentService.GetApiUrl()}/game-server-node/ping/{serverId}?map={mapName}&pluginVersion={pluginVersion}&pluginRuntime=swiftlys2"; if (_steamRelay) { @@ -157,19 +154,6 @@ public void Ping(string pluginVersion) cts.Token ); response.EnsureSuccessStatusCode(); - - // Only with nothing loaded: a server that has a match is awake, - // and the panel reaches an awake server over RCON as before. - if ( - matchId == null - && PingReplyUtility.WantsMatch( - await response.Content.ReadAsStringAsync(cts.Token) - ) - ) - { - _logger.LogInformation("the panel has a match for this server"); - _matchService.GetMatchFromApi(); - } } catch (OperationCanceledException) { diff --git a/apps/swiftly/src/FiveStack.Services/MatchService.cs b/apps/swiftly/src/FiveStack.Services/MatchService.cs index bf31cc22..2be092dc 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchService.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchService.cs @@ -184,9 +184,10 @@ public async void GetMatchFromApi() private bool _heldAwake; - // A hibernating server runs no ticks and cannot be reached over RCON, so it - // only hibernates while it has nothing to do: it is held awake from the - // moment it is given a match until that match is gone. + // A hibernating server runs no ticks, so timers and the end-of-map work + // would stall once everyone left. It only hibernates while it has nothing + // to do: it is held awake from the moment it is given a match until that + // match is gone. public void StayAwake(bool awake) { try diff --git a/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs index 5602295d..04f754b7 100644 --- a/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs @@ -32,12 +32,9 @@ public static void Shutdown() } } - public static bool IsTicking => - Environment.TickCount64 - Volatile.Read(ref _lastTickMs) < TickingWindowMs; - public static void NextTick(Action callback) { - if (IsTicking) + if (Environment.TickCount64 - Volatile.Read(ref _lastTickMs) < TickingWindowMs) { _core.Scheduler.NextTick(callback); return; diff --git a/apps/swiftly/test/PingReplyUtilityTests.cs b/apps/swiftly/test/PingReplyUtilityTests.cs deleted file mode 100644 index 6f9c9b06..00000000 --- a/apps/swiftly/test/PingReplyUtilityTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using FiveStack.Utilities; -using Xunit; - -public class PingReplyUtilityTests -{ - [Fact] - public void AMatchIsFetchedOnlyWhenThePanelAsksForIt() - { - Assert.True(PingReplyUtility.WantsMatch("""{"get_match":true}""")); - Assert.False(PingReplyUtility.WantsMatch("""{"get_match":false}""")); - } - - // Panels older than this reply with nothing at all. - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData("{}")] - [InlineData("[]")] - [InlineData("OK")] - [InlineData("""{"get_match":"true"}""")] - public void AnythingElseIsNotARequest(string? body) - { - Assert.False(PingReplyUtility.WantsMatch(body)); - } -} diff --git a/shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs b/shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs deleted file mode 100644 index 2307b6f2..00000000 --- a/shared/dotnet/FiveStack.Utilities/PingReplyUtility.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Text.Json; - -namespace FiveStack.Utilities; - -// A hibernating server does not answer RCON, so the panel cannot push -// `get_match` to it. It answers the server's own ping instead. -public static class PingReplyUtility -{ - public static bool WantsMatch(string? body) - { - if (string.IsNullOrWhiteSpace(body)) - { - return false; - } - - try - { - using JsonDocument reply = JsonDocument.Parse(body); - - return reply.RootElement.ValueKind == JsonValueKind.Object - && reply.RootElement.TryGetProperty("get_match", out JsonElement wants) - && wants.ValueKind == JsonValueKind.True; - } - catch (JsonException) - { - return false; - } - } -} From 4262449ccf875b681b89ee57aa4c32dbda9b5f32 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 21:42:55 -0400 Subject: [PATCH 9/9] feature: hold a CounterStrikeSharp server awake for its match too --- .../src/FiveStack.Services/MatchService.cs | 40 +++++++++++++++++++ .../counterstrikesharp/src/FiveStackPlugin.cs | 1 + 2 files changed, 41 insertions(+) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs index 274ec127..d5c85e64 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CounterStrikeSharp.API; +using CounterStrikeSharp.API.Modules.Cvars; using FiveStack.Entities; using FiveStack.Utilities; using Microsoft.Extensions.DependencyInjection; @@ -126,6 +127,8 @@ public async void GetMatchFromApi() _currentMatch = null; + StayAwake(false); + _logger.LogWarning( $"No match assigned — clearing match {previousMatchId?.ToString() ?? "none"}" ); @@ -139,6 +142,8 @@ public async void GetMatchFromApi() return; } + StayAwake(true); + if (previousMatchId == matchData.id) { _logger.LogInformation( @@ -178,6 +183,41 @@ public async void GetMatchFromApi() } } + private bool _heldAwake; + + // A hibernating server runs no frames, so timers and the end-of-map work + // would stall once everyone left. It only hibernates while it has nothing + // to do: it is held awake from the moment it is given a match until that + // match is gone. + public void StayAwake(bool awake) + { + ConVar? hibernate = ConVar.Find("sv_hibernate_when_empty"); + + if (hibernate == null) + { + return; + } + + if (awake) + { + if (hibernate.GetPrimitiveValue()) + { + hibernate.SetValue(false); + _heldAwake = true; + _logger.LogInformation("holding the server awake for its match"); + } + + return; + } + + if (_heldAwake) + { + hibernate.SetValue(true); + _heldAwake = false; + _logger.LogInformation("no match left, the server may hibernate again"); + } + } + public void GetMatchFromOffline() { string? offlineMatchData = _environmentService.GetOfflineMatchData(); diff --git a/apps/counterstrikesharp/src/FiveStackPlugin.cs b/apps/counterstrikesharp/src/FiveStackPlugin.cs index 745abd87..81e22f69 100644 --- a/apps/counterstrikesharp/src/FiveStackPlugin.cs +++ b/apps/counterstrikesharp/src/FiveStackPlugin.cs @@ -141,6 +141,7 @@ public override void Unload(bool hotReload) ); _matchService.GetCurrentMatch()?.Reset(); + _matchService.StayAwake(false); _ = _matchEvents.Disconnect(); }