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/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 860ae864..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( @@ -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) { 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..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; @@ -89,7 +90,7 @@ public async void GetMatchFromApi() if (serverId == null || apiPassword == null) { await Task.Delay(1000 * 5); - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { GetMatchFromApi(); }); @@ -113,7 +114,7 @@ public async void GetMatchFromApi() httpResponse.EnsureSuccessStatusCode(); string? response = await httpResponse.Content.ReadAsStringAsync(); - Server.NextFrame(() => + HibernationUtility.NextFrame(() => { Guid? previousMatchId = _currentMatch?.GetMatchData()?.id; @@ -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/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..81e22f69 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, @@ -140,6 +141,7 @@ public override void Unload(bool hotReload) ); _matchService.GetCurrentMatch()?.Reset(); + _matchService.StayAwake(false); _ = _matchEvents.Disconnect(); } 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/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 c8084781..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( @@ -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.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..2be092dc 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; @@ -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,49 @@ public async void GetMatchFromApi() } } + private bool _heldAwake; + + // 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 + { + 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 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 00fde5a8..2804be1c 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; @@ -99,6 +101,7 @@ public override void Load(bool hotReload) MatchUtility.Initialize(Core); TimerUtility.Initialize(Core); + HibernationUtility.Initialize(Core); _environmentService = _serviceProvider.GetRequiredService(); _steamService = _serviceProvider.GetRequiredService(); @@ -192,8 +195,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() @@ -225,7 +245,10 @@ public override void Unload() UninstallConnectClientHook(); - TimerUtility.Kill(_pingTimer); + if (_pingHandler != null) + { + Core.Event.OnWorldUpdate -= _pingHandler; + } // SwiftlyS2 only tears a plugin down once Unload returns, and a hot // reload loads the new build either way: an exception here would leave @@ -235,6 +258,7 @@ public override void Unload() ClanTagUtility.Unhook(); _matchService.GetCurrentMatch()?.Reset(); + _matchService.StayAwake(false); _ = _matchEvents.Disconnect(); } @@ -245,6 +269,7 @@ public override void Unload() finally { TimerUtility.ClearAll(); + HibernationUtility.Shutdown(); } } } 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: