From 08aabb0b6db8ccffc5d0a39e17ef1856f702f135 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 22:02:47 -0400 Subject: [PATCH] feature: report hibernation on the ping, and stop practice timers replaying after it --- .../src/FiveStack.Services/GameServer.cs | 2 +- .../FiveStack.Utilities/HibernationUtility.cs | 2 + .../src/FiveStack.Services/GameServer.cs | 2 +- .../FiveStack.Utilities/HibernationUtility.cs | 42 ++++++++++++++++++- apps/utility-sw/src/UtilityPracticePlugin.cs | 24 ++++++++--- 5 files changed, 64 insertions(+), 8 deletions(-) diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs index 29d597ef..fca29d03 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs @@ -99,7 +99,7 @@ public void Ping(string pluginVersion) string? workshopID = _matchService.GetWorkshopID(); string endpoint = - $"{_environmentService.GetApiUrl()}/game-server-node/ping/{serverId}?map={(string.IsNullOrEmpty(workshopID) ? Server.MapName : workshopID)}&pluginVersion={pluginVersion}&pluginRuntime=counterstrikesharp"; + $"{_environmentService.GetApiUrl()}/game-server-node/ping/{serverId}?map={(string.IsNullOrEmpty(workshopID) ? Server.MapName : workshopID)}&pluginVersion={pluginVersion}&pluginRuntime=counterstrikesharp&hibernating={(HibernationUtility.IsHibernating ? "true" : "false")}"; if (_steamRelay) { diff --git a/apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs b/apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs index 5113b09b..d97c1a2a 100644 --- a/apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs +++ b/apps/counterstrikesharp/src/FiveStack.Utilities/HibernationUtility.cs @@ -11,6 +11,8 @@ public static class HibernationUtility { private static volatile bool _hibernating; + public static bool IsHibernating => _hibernating; + public static void SetHibernating(bool hibernating) { _hibernating = hibernating; diff --git a/apps/swiftly/src/FiveStack.Services/GameServer.cs b/apps/swiftly/src/FiveStack.Services/GameServer.cs index bce2f6c8..60aae5e3 100644 --- a/apps/swiftly/src/FiveStack.Services/GameServer.cs +++ b/apps/swiftly/src/FiveStack.Services/GameServer.cs @@ -123,7 +123,7 @@ public void Ping(string pluginVersion) : workshopID; 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&hibernating={(HibernationUtility.IsHibernating ? "true" : "false")}"; if (_steamRelay) { diff --git a/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs index 04f754b7..c4c991f6 100644 --- a/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs +++ b/apps/swiftly/src/FiveStack.Utilities/HibernationUtility.cs @@ -11,16 +11,22 @@ namespace FiveStack.Utilities; public static class HibernationUtility { private const long TickingWindowMs = 250; + private const long HibernatingAfterMs = 5000; private static ISwiftlyCore _core = null!; private static EventDelegates.OnTick? _tickHandler; + private static EventDelegates.OnWorldUpdate? _worldUpdateHandler; private static long _lastTickMs; + private static long _updatingWithoutTicksSinceMs; public static void Initialize(ISwiftlyCore core) { _core = core; _tickHandler = () => Volatile.Write(ref _lastTickMs, Environment.TickCount64); _core.Event.OnTick += _tickHandler; + + _worldUpdateHandler = OnWorldUpdate; + _core.Event.OnWorldUpdate += _worldUpdateHandler; } public static void Shutdown() @@ -30,11 +36,45 @@ public static void Shutdown() _core.Event.OnTick -= _tickHandler; _tickHandler = null; } + + if (_worldUpdateHandler != null) + { + _core.Event.OnWorldUpdate -= _worldUpdateHandler; + _worldUpdateHandler = null; + } + } + + private static bool IsTicking => + Environment.TickCount64 - Volatile.Read(ref _lastTickMs) < TickingWindowMs; + + // A map load stops ticks and world updates together and plugin load starts + // with neither seen, so only world updates that keep arriving with no tick + // between them say the server is hibernating. + private static void OnWorldUpdate() + { + if (IsTicking) + { + Volatile.Write(ref _updatingWithoutTicksSinceMs, 0); + } + else if (Volatile.Read(ref _updatingWithoutTicksSinceMs) == 0) + { + Volatile.Write(ref _updatingWithoutTicksSinceMs, Environment.TickCount64); + } + } + + public static bool IsHibernating + { + get + { + long since = Volatile.Read(ref _updatingWithoutTicksSinceMs); + + return since != 0 && Environment.TickCount64 - since > HibernatingAfterMs; + } } 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/utility-sw/src/UtilityPracticePlugin.cs b/apps/utility-sw/src/UtilityPracticePlugin.cs index aa1a0ade..3bfacab9 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, Surviving(OnSecond)); - _refillTimer = Core.Scheduler.RepeatBySeconds(0.1f, Surviving(OnFastTick)); + _secondTimer = Core.Scheduler.RepeatBySeconds(1, Surviving(1, OnSecond)); + _refillTimer = Core.Scheduler.RepeatBySeconds(0.1f, Surviving(0.1f, 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 @@ -302,10 +302,15 @@ public override void Load(bool hotReload) 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) + // SwiftlyS2 never reschedules a repeating timer whose callback throws, + // still runs a timer's first pass if it was cancelled before then, and + // after a spell without ticks replays every run it missed, one a tick. The + // gap check turns that replay into at most twice the usual rate. + private Action Surviving(float interval, Action callback) { + long lastRunMs = 0; + long minimumGapMs = (long)(interval * 500); + return () => { if (_tornDown) @@ -313,6 +318,15 @@ private Action Surviving(Action callback) return; } + long nowMs = Environment.TickCount64; + + if (nowMs - lastRunMs < minimumGapMs) + { + return; + } + + lastRunMs = nowMs; + try { callback();