From a4e7fc8011b8931da6b22c8aafe3d8a87d420b44 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 13:16:36 -0400 Subject: [PATCH 1/2] 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 c368a8a3da248a1c1302ceeaf857c730b82ef821 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 5 Oct 2026 13:49:07 -0400 Subject: [PATCH 2/2] 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.