From 5874690883cfc41fb44ac90be00cc6fd4f1c6268 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:43:15 +0000 Subject: [PATCH 01/15] Initial plan From 2e6b4b3f7818bfed68ad9da29f2b8eb13e833acf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:55:43 +0000 Subject: [PATCH 02/15] Add Timer class polyfill for .NET Standard 1.0-1.1 using Task.Delay Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim.Tests/NetCore10/TimerTests.cs | 121 +++++++++++++++++++++++++ PolyShim/Net80/TimeProvider.cs | 4 - PolyShim/NetCore10/Timer.cs | 119 ++++++++++++++++++++++++ PolyShim/NetCore30/Timer.cs | 3 - PolyShim/Signatures.md | 5 +- 5 files changed, 243 insertions(+), 9 deletions(-) create mode 100644 PolyShim.Tests/NetCore10/TimerTests.cs create mode 100644 PolyShim/NetCore10/Timer.cs diff --git a/PolyShim.Tests/NetCore10/TimerTests.cs b/PolyShim.Tests/NetCore10/TimerTests.cs new file mode 100644 index 0000000..063f6a4 --- /dev/null +++ b/PolyShim.Tests/NetCore10/TimerTests.cs @@ -0,0 +1,121 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace PolyShim.Tests.NetCore10; + +public class TimerTests +{ + [Fact] + public async Task Timer_FiresCallback_AfterDueTime_Test() + { + // Arrange + var fired = false; + + // Act + using var timer = new Timer( + _ => fired = true, + null, + TimeSpan.FromMilliseconds(50), + Timeout.InfiniteTimeSpan + ); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + + // Assert + fired.Should().BeTrue(); + } + + [Fact] + public async Task Timer_FiresCallbackRepeatedly_WithPeriod_Test() + { + // Arrange + var count = 0; + + // Act + using var timer = new Timer( + _ => Interlocked.Increment(ref count), + null, + TimeSpan.Zero, + TimeSpan.FromMilliseconds(50) + ); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + + // Assert + count.Should().BeGreaterThan(1); + } + + [Fact] + public async Task Timer_DoesNotFire_WhenDueTimeIsInfinite_Test() + { + // Arrange + var fired = false; + + // Act + using var timer = new Timer( + _ => fired = true, + null, + Timeout.InfiniteTimeSpan, + Timeout.InfiniteTimeSpan + ); + await Task.Delay(TimeSpan.FromMilliseconds(200)); + + // Assert + fired.Should().BeFalse(); + } + + [Fact] + public async Task Timer_Change_UpdatesDueTime_Test() + { + // Arrange + var fired = false; + using var timer = new Timer( + _ => fired = true, + null, + Timeout.InfiniteTimeSpan, + Timeout.InfiniteTimeSpan + ); + + // Act + timer.Change(TimeSpan.FromMilliseconds(50), Timeout.InfiniteTimeSpan); + await Task.Delay(TimeSpan.FromMilliseconds(500)); + + // Assert + fired.Should().BeTrue(); + } + + [Fact] + public async Task Timer_Dispose_StopsFiring_Test() + { + // Arrange + var count = 0; + var timer = new Timer( + _ => Interlocked.Increment(ref count), + null, + TimeSpan.FromMilliseconds(100), + TimeSpan.FromMilliseconds(100) + ); + + // Act + timer.Dispose(); + await Task.Delay(TimeSpan.FromMilliseconds(400)); + + // Assert + count.Should().Be(0); + } + + [Fact] + public void Timer_Change_ReturnsFalse_AfterDispose_Test() + { + // Arrange + var timer = new Timer(_ => { }, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + timer.Dispose(); + + // Act + var result = timer.Change(TimeSpan.FromMilliseconds(50), Timeout.InfiniteTimeSpan); + + // Assert + result.Should().BeFalse(); + } +} diff --git a/PolyShim/Net80/TimeProvider.cs b/PolyShim/Net80/TimeProvider.cs index bf4dc54..ab01b0c 100644 --- a/PolyShim/Net80/TimeProvider.cs +++ b/PolyShim/Net80/TimeProvider.cs @@ -40,21 +40,18 @@ public virtual TimeSpan GetElapsedTime(long startingTimestamp, long endingTimest public TimeSpan GetElapsedTime(long startingTimestamp) => GetElapsedTime(startingTimestamp, GetTimestamp()); -#if !(NETSTANDARD && !NETSTANDARD1_2_OR_GREATER) public virtual ITimer CreateTimer( TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period ) => new SystemTimeProviderTimer(dueTime, period, callback, state); -#endif private sealed class SystemTimeProvider : TimeProvider { public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Local; } -#if !(NETSTANDARD && !NETSTANDARD1_2_OR_GREATER) private sealed class SystemTimeProviderTimer : ITimer { private readonly Timer _timer; @@ -90,6 +87,5 @@ public void Dispose() public ValueTask DisposeAsync() => _timer.DisposeAsync(); #endif } -#endif } #endif diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs new file mode 100644 index 0000000..7c50075 --- /dev/null +++ b/PolyShim/NetCore10/Timer.cs @@ -0,0 +1,119 @@ +#if NETSTANDARD && !NETSTANDARD1_2_OR_GREATER +#nullable enable +// ReSharper disable RedundantUsingDirective +// ReSharper disable CheckNamespace +// ReSharper disable InconsistentNaming +// ReSharper disable PartialTypeWithSinglePart + +using System.Diagnostics.CodeAnalysis; +using System.Threading.Tasks; + +namespace System.Threading; + +// https://learn.microsoft.com/dotnet/api/system.threading.timer +[ExcludeFromCodeCoverage] +internal sealed class Timer : IDisposable +{ + private readonly TimerCallback _callback; + private readonly object? _state; + private CancellationTokenSource _cts = new CancellationTokenSource(); + private volatile bool _disposed; + + public Timer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + _callback = callback; + _state = state; + Schedule(dueTime, period); + } + + public Timer(TimerCallback callback, object? state, int dueTime, int period) + : this( + callback, + state, + TimeSpan.FromMilliseconds(dueTime), + TimeSpan.FromMilliseconds(period) + ) { } + + public Timer(TimerCallback callback, object? state, long dueTime, long period) + : this( + callback, + state, + TimeSpan.FromMilliseconds(dueTime), + TimeSpan.FromMilliseconds(period) + ) { } + + public Timer(TimerCallback callback) + : this(callback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) { } + + private void Schedule(TimeSpan dueTime, TimeSpan period) + { + if (_disposed) + throw new ObjectDisposedException(nameof(Timer)); + + var cts = new CancellationTokenSource(); + var oldCts = Interlocked.Exchange(ref _cts, cts); + oldCts.Cancel(); + oldCts.Dispose(); + + if (dueTime == Timeout.InfiniteTimeSpan) + return; + + _ = Task.Run(async () => + { + try + { + if (dueTime > TimeSpan.Zero) + await Task.Delay(dueTime, cts.Token); + + if (cts.IsCancellationRequested) + return; + + _callback(_state); + + if (period == Timeout.InfiniteTimeSpan || period <= TimeSpan.Zero) + return; + + while (!cts.IsCancellationRequested) + { + await Task.Delay(period, cts.Token); + + if (cts.IsCancellationRequested) + return; + + _callback(_state); + } + } + catch (OperationCanceledException) { } + }); + } + + public bool Change(TimeSpan dueTime, TimeSpan period) + { + try + { + Schedule(dueTime, period); + return true; + } + catch + { + return false; + } + } + + public bool Change(int dueTime, int period) => + Change(TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMilliseconds(period)); + + public bool Change(long dueTime, long period) => + Change(TimeSpan.FromMilliseconds(dueTime), TimeSpan.FromMilliseconds(period)); + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + var cts = Interlocked.Exchange(ref _cts, new CancellationTokenSource()); + cts.Cancel(); + cts.Dispose(); + } +} +#endif diff --git a/PolyShim/NetCore30/Timer.cs b/PolyShim/NetCore30/Timer.cs index b2ae961..1afc8b5 100644 --- a/PolyShim/NetCore30/Timer.cs +++ b/PolyShim/NetCore30/Timer.cs @@ -1,6 +1,4 @@ #if (NETCOREAPP && !NETCOREAPP3_0_OR_GREATER) || (NETFRAMEWORK) || (NETSTANDARD && !NETSTANDARD2_1_OR_GREATER) -// Timer is not available on .NET Standard 1.0 and 1.1 -#if !(NETSTANDARD && !NETSTANDARD1_2_OR_GREATER) #nullable enable // ReSharper disable RedundantUsingDirective // ReSharper disable CheckNamespace @@ -27,4 +25,3 @@ public ValueTask DisposeAsync() } } #endif -#endif diff --git a/PolyShim/Signatures.md b/PolyShim/Signatures.md index 8a19aca..0b398ad 100644 --- a/PolyShim/Signatures.md +++ b/PolyShim/Signatures.md @@ -1,7 +1,7 @@ # Signatures -- **Total:** 372 -- **Types:** 78 +- **Total:** 373 +- **Types:** 79 - **Members:** 294 ___ @@ -462,6 +462,7 @@ ___ - `TimeProvider` - [**[class]**](https://learn.microsoft.com/dotnet/api/system.timeprovider) .NET 8.0 - `Timer` + - [**[class]**](https://learn.microsoft.com/dotnet/api/system.threading.timer) .NET Core 1.0 - [`ValueTask DisposeAsync()`](https://learn.microsoft.com/dotnet/api/system.threading.timer.disposeasync) .NET Core 3.0 - `TimeSpan` - [`TimeSpan FromMilliseconds(long, long)`](https://learn.microsoft.com/dotnet/api/system.timespan.frommilliseconds#system-timespan-frommilliseconds(system-int64-system-int64)) .NET 9.0 From 69ef4db6cf82fc9f87bf9bc7994cb2b0e9372076 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:57:18 +0000 Subject: [PATCH 03/15] Remove Timer tests (can't run against netstandard 1.0-1.1 targets) Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim.Tests/NetCore10/TimerTests.cs | 121 ------------------------- 1 file changed, 121 deletions(-) delete mode 100644 PolyShim.Tests/NetCore10/TimerTests.cs diff --git a/PolyShim.Tests/NetCore10/TimerTests.cs b/PolyShim.Tests/NetCore10/TimerTests.cs deleted file mode 100644 index 063f6a4..0000000 --- a/PolyShim.Tests/NetCore10/TimerTests.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using FluentAssertions; -using Xunit; - -namespace PolyShim.Tests.NetCore10; - -public class TimerTests -{ - [Fact] - public async Task Timer_FiresCallback_AfterDueTime_Test() - { - // Arrange - var fired = false; - - // Act - using var timer = new Timer( - _ => fired = true, - null, - TimeSpan.FromMilliseconds(50), - Timeout.InfiniteTimeSpan - ); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - // Assert - fired.Should().BeTrue(); - } - - [Fact] - public async Task Timer_FiresCallbackRepeatedly_WithPeriod_Test() - { - // Arrange - var count = 0; - - // Act - using var timer = new Timer( - _ => Interlocked.Increment(ref count), - null, - TimeSpan.Zero, - TimeSpan.FromMilliseconds(50) - ); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - // Assert - count.Should().BeGreaterThan(1); - } - - [Fact] - public async Task Timer_DoesNotFire_WhenDueTimeIsInfinite_Test() - { - // Arrange - var fired = false; - - // Act - using var timer = new Timer( - _ => fired = true, - null, - Timeout.InfiniteTimeSpan, - Timeout.InfiniteTimeSpan - ); - await Task.Delay(TimeSpan.FromMilliseconds(200)); - - // Assert - fired.Should().BeFalse(); - } - - [Fact] - public async Task Timer_Change_UpdatesDueTime_Test() - { - // Arrange - var fired = false; - using var timer = new Timer( - _ => fired = true, - null, - Timeout.InfiniteTimeSpan, - Timeout.InfiniteTimeSpan - ); - - // Act - timer.Change(TimeSpan.FromMilliseconds(50), Timeout.InfiniteTimeSpan); - await Task.Delay(TimeSpan.FromMilliseconds(500)); - - // Assert - fired.Should().BeTrue(); - } - - [Fact] - public async Task Timer_Dispose_StopsFiring_Test() - { - // Arrange - var count = 0; - var timer = new Timer( - _ => Interlocked.Increment(ref count), - null, - TimeSpan.FromMilliseconds(100), - TimeSpan.FromMilliseconds(100) - ); - - // Act - timer.Dispose(); - await Task.Delay(TimeSpan.FromMilliseconds(400)); - - // Assert - count.Should().Be(0); - } - - [Fact] - public void Timer_Change_ReturnsFalse_AfterDispose_Test() - { - // Arrange - var timer = new Timer(_ => { }, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); - timer.Dispose(); - - // Act - var result = timer.Change(TimeSpan.FromMilliseconds(50), Timeout.InfiniteTimeSpan); - - // Assert - result.Should().BeFalse(); - } -} From 2be4f52ee9ead5ac87dc04b9fbb9cab14d4fe500 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:29:27 +0000 Subject: [PATCH 04/15] Address review feedback on Timer polyfill Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 76 ++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index 7c50075..69b2a2c 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -12,20 +12,16 @@ namespace System.Threading; // https://learn.microsoft.com/dotnet/api/system.threading.timer [ExcludeFromCodeCoverage] -internal sealed class Timer : IDisposable +internal sealed class Timer( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period +) : IDisposable { - private readonly TimerCallback _callback; - private readonly object? _state; - private CancellationTokenSource _cts = new CancellationTokenSource(); + private CancellationTokenSource _cts = CreateAndStart(callback, state, dueTime, period); private volatile bool _disposed; - public Timer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - _callback = callback; - _state = state; - Schedule(dueTime, period); - } - public Timer(TimerCallback callback, object? state, int dueTime, int period) : this( callback, @@ -45,18 +41,26 @@ public Timer(TimerCallback callback, object? state, long dueTime, long period) public Timer(TimerCallback callback) : this(callback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) { } - private void Schedule(TimeSpan dueTime, TimeSpan period) + private static void ValidateTimes(TimeSpan dueTime, TimeSpan period) { - if (_disposed) - throw new ObjectDisposedException(nameof(Timer)); + if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(dueTime)); + if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(period)); + } + private static CancellationTokenSource CreateAndStart( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period + ) + { + ValidateTimes(dueTime, period); var cts = new CancellationTokenSource(); - var oldCts = Interlocked.Exchange(ref _cts, cts); - oldCts.Cancel(); - oldCts.Dispose(); if (dueTime == Timeout.InfiniteTimeSpan) - return; + return cts; _ = Task.Run(async () => { @@ -68,36 +72,45 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) if (cts.IsCancellationRequested) return; - _callback(_state); + callback(state); - if (period == Timeout.InfiniteTimeSpan || period <= TimeSpan.Zero) + if (period == Timeout.InfiniteTimeSpan) return; while (!cts.IsCancellationRequested) { - await Task.Delay(period, cts.Token); + if (period > TimeSpan.Zero) + await Task.Delay(period, cts.Token); + else + await Task.Yield(); if (cts.IsCancellationRequested) return; - _callback(_state); + callback(state); } } catch (OperationCanceledException) { } }); + + return cts; + } + + private void Schedule(TimeSpan dueTime, TimeSpan period) + { + if (_disposed) + throw new ObjectDisposedException(nameof(Timer)); + + var cts = CreateAndStart(callback, state, dueTime, period); + var oldCts = Interlocked.Exchange(ref _cts, cts); + oldCts.Cancel(); + oldCts.Dispose(); } public bool Change(TimeSpan dueTime, TimeSpan period) { - try - { - Schedule(dueTime, period); - return true; - } - catch - { - return false; - } + Schedule(dueTime, period); + return true; } public bool Change(int dueTime, int period) => @@ -110,6 +123,7 @@ public void Dispose() { if (_disposed) return; + _disposed = true; var cts = Interlocked.Exchange(ref _cts, new CancellationTokenSource()); cts.Cancel(); From 585ca1fee3f669c7b0b111ae4246f7ae02db68d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:35:46 +0000 Subject: [PATCH 05/15] Fix callback exception surfacing in Timer polyfill Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index 69b2a2c..cf0f947 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -68,29 +68,39 @@ TimeSpan period { if (dueTime > TimeSpan.Zero) await Task.Delay(dueTime, cts.Token); + } + catch (OperationCanceledException) + { + return; + } - if (cts.IsCancellationRequested) - return; + if (cts.IsCancellationRequested) + return; - callback(state); + callback(state); - if (period == Timeout.InfiniteTimeSpan) - return; + if (period == Timeout.InfiniteTimeSpan) + return; - while (!cts.IsCancellationRequested) + while (!cts.IsCancellationRequested) + { + try { if (period > TimeSpan.Zero) await Task.Delay(period, cts.Token); else await Task.Yield(); + } + catch (OperationCanceledException) + { + return; + } - if (cts.IsCancellationRequested) - return; + if (cts.IsCancellationRequested) + return; - callback(state); - } + callback(state); } - catch (OperationCanceledException) { } }); return cts; From fed084c61b4c13dc70d795b134d76bff4544e2b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:38:57 +0000 Subject: [PATCH 06/15] Inline ValidateTimes; fix Dispose CTS leak Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index cf0f947..0e78f94 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -41,14 +41,6 @@ public Timer(TimerCallback callback, object? state, long dueTime, long period) public Timer(TimerCallback callback) : this(callback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) { } - private static void ValidateTimes(TimeSpan dueTime, TimeSpan period) - { - if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(dueTime)); - if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(period)); - } - private static CancellationTokenSource CreateAndStart( TimerCallback callback, object? state, @@ -56,7 +48,11 @@ private static CancellationTokenSource CreateAndStart( TimeSpan period ) { - ValidateTimes(dueTime, period); + if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(dueTime)); + if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(period)); + var cts = new CancellationTokenSource(); if (dueTime == Timeout.InfiniteTimeSpan) @@ -135,9 +131,8 @@ public void Dispose() return; _disposed = true; - var cts = Interlocked.Exchange(ref _cts, new CancellationTokenSource()); - cts.Cancel(); - cts.Dispose(); + _cts.Cancel(); + _cts.Dispose(); } } #endif From ac45d376aa0cfa06898d60ffd7508fadab0beb35 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:32:01 +0200 Subject: [PATCH 07/15] Update PolyShim/NetCore10/Timer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index 0e78f94..c88412d 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -111,6 +111,15 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) var oldCts = Interlocked.Exchange(ref _cts, cts); oldCts.Cancel(); oldCts.Dispose(); + + // Handle race where Dispose completes after the initial _disposed check + // but before/just after the exchange: ensure the newly created CTS + // is also cancelled and disposed so it doesn't leak or keep firing. + if (_disposed) + { + cts.Cancel(); + cts.Dispose(); + } } public bool Change(TimeSpan dueTime, TimeSpan period) From 2cc00c7f06f9e52bba4a7b1f738be45ab43442c7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:33:49 +0000 Subject: [PATCH 08/15] Fix period==0 behavior, Dispose/Change race, and null callback validation Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index c88412d..8fe9427 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -48,6 +48,8 @@ private static CancellationTokenSource CreateAndStart( TimeSpan period ) { + if (callback is null) + throw new ArgumentNullException(nameof(callback)); if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(dueTime)); if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) @@ -75,17 +77,14 @@ TimeSpan period callback(state); - if (period == Timeout.InfiniteTimeSpan) + if (period == Timeout.InfiniteTimeSpan || period == TimeSpan.Zero) return; while (!cts.IsCancellationRequested) { try { - if (period > TimeSpan.Zero) - await Task.Delay(period, cts.Token); - else - await Task.Yield(); + await Task.Delay(period, cts.Token); } catch (OperationCanceledException) { From 4703f2bb6b13c5bef6847d853ca7c11b3a38dd90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 17:47:57 +0000 Subject: [PATCH 09/15] Refactor Start to accept CancellationToken instead of CancellationTokenSource Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 47 ++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index 8fe9427..f7e5b4e 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -19,7 +19,7 @@ internal sealed class Timer( TimeSpan period ) : IDisposable { - private CancellationTokenSource _cts = CreateAndStart(callback, state, dueTime, period); + private CancellationTokenSource _cts = CreateAndSchedule(callback, state, dueTime, period); private volatile bool _disposed; public Timer(TimerCallback callback, object? state, int dueTime, int period) @@ -41,38 +41,30 @@ public Timer(TimerCallback callback, object? state, long dueTime, long period) public Timer(TimerCallback callback) : this(callback, null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) { } - private static CancellationTokenSource CreateAndStart( + private static void Start( TimerCallback callback, object? state, TimeSpan dueTime, - TimeSpan period + TimeSpan period, + CancellationToken cancellationToken ) { - if (callback is null) - throw new ArgumentNullException(nameof(callback)); - if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(dueTime)); - if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(period)); - - var cts = new CancellationTokenSource(); - if (dueTime == Timeout.InfiniteTimeSpan) - return cts; + return; _ = Task.Run(async () => { try { if (dueTime > TimeSpan.Zero) - await Task.Delay(dueTime, cts.Token); + await Task.Delay(dueTime, cancellationToken); } catch (OperationCanceledException) { return; } - if (cts.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested) return; callback(state); @@ -80,24 +72,41 @@ TimeSpan period if (period == Timeout.InfiniteTimeSpan || period == TimeSpan.Zero) return; - while (!cts.IsCancellationRequested) + while (!cancellationToken.IsCancellationRequested) { try { - await Task.Delay(period, cts.Token); + await Task.Delay(period, cancellationToken); } catch (OperationCanceledException) { return; } - if (cts.IsCancellationRequested) + if (cancellationToken.IsCancellationRequested) return; callback(state); } }); + } + + private static CancellationTokenSource CreateAndSchedule( + TimerCallback callback, + object? state, + TimeSpan dueTime, + TimeSpan period + ) + { + if (callback is null) + throw new ArgumentNullException(nameof(callback)); + if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(dueTime)); + if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(period)); + var cts = new CancellationTokenSource(); + Start(callback, state, dueTime, period, cts.Token); return cts; } @@ -106,7 +115,7 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) if (_disposed) throw new ObjectDisposedException(nameof(Timer)); - var cts = CreateAndStart(callback, state, dueTime, period); + var cts = CreateAndSchedule(callback, state, dueTime, period); var oldCts = Interlocked.Exchange(ref _cts, cts); oldCts.Cancel(); oldCts.Dispose(); From 7f70debe4d6672f1924953d9de5f7ecf074f849f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:52:27 +0000 Subject: [PATCH 10/15] Inline CreateAndSchedule into Schedule; keep CreateCts for field initializer only Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index f7e5b4e..e8e2551 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -19,7 +19,7 @@ internal sealed class Timer( TimeSpan period ) : IDisposable { - private CancellationTokenSource _cts = CreateAndSchedule(callback, state, dueTime, period); + private CancellationTokenSource _cts = CreateCts(callback, state, dueTime, period); private volatile bool _disposed; public Timer(TimerCallback callback, object? state, int dueTime, int period) @@ -91,7 +91,7 @@ CancellationToken cancellationToken }); } - private static CancellationTokenSource CreateAndSchedule( + private static CancellationTokenSource CreateCts( TimerCallback callback, object? state, TimeSpan dueTime, @@ -115,7 +115,13 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) if (_disposed) throw new ObjectDisposedException(nameof(Timer)); - var cts = CreateAndSchedule(callback, state, dueTime, period); + if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(dueTime)); + if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(period)); + + var cts = new CancellationTokenSource(); + Start(callback, state, dueTime, period, cts.Token); var oldCts = Interlocked.Exchange(ref _cts, cts); oldCts.Cancel(); oldCts.Dispose(); From 6e859db59be368f95cba6024204297791a48f3a3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:18:05 +0000 Subject: [PATCH 11/15] Remove CreateCts by making _cts nullable with lazy init via primary constructor refactor Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 42 +++++++++++-------------------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index e8e2551..47bfd93 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -12,16 +12,17 @@ namespace System.Threading; // https://learn.microsoft.com/dotnet/api/system.threading.timer [ExcludeFromCodeCoverage] -internal sealed class Timer( - TimerCallback callback, - object? state, - TimeSpan dueTime, - TimeSpan period -) : IDisposable +internal sealed class Timer(TimerCallback callback, object? state) : IDisposable { - private CancellationTokenSource _cts = CreateCts(callback, state, dueTime, period); + private CancellationTokenSource? _cts; private volatile bool _disposed; + public Timer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + : this(callback ?? throw new ArgumentNullException(nameof(callback)), state) + { + Schedule(dueTime, period); + } + public Timer(TimerCallback callback, object? state, int dueTime, int period) : this( callback, @@ -91,25 +92,6 @@ CancellationToken cancellationToken }); } - private static CancellationTokenSource CreateCts( - TimerCallback callback, - object? state, - TimeSpan dueTime, - TimeSpan period - ) - { - if (callback is null) - throw new ArgumentNullException(nameof(callback)); - if (dueTime != Timeout.InfiniteTimeSpan && dueTime < TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(dueTime)); - if (period != Timeout.InfiniteTimeSpan && period < TimeSpan.Zero) - throw new ArgumentOutOfRangeException(nameof(period)); - - var cts = new CancellationTokenSource(); - Start(callback, state, dueTime, period, cts.Token); - return cts; - } - private void Schedule(TimeSpan dueTime, TimeSpan period) { if (_disposed) @@ -123,8 +105,8 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) var cts = new CancellationTokenSource(); Start(callback, state, dueTime, period, cts.Token); var oldCts = Interlocked.Exchange(ref _cts, cts); - oldCts.Cancel(); - oldCts.Dispose(); + oldCts?.Cancel(); + oldCts?.Dispose(); // Handle race where Dispose completes after the initial _disposed check // but before/just after the exchange: ensure the newly created CTS @@ -154,8 +136,8 @@ public void Dispose() return; _disposed = true; - _cts.Cancel(); - _cts.Dispose(); + _cts?.Cancel(); + _cts?.Dispose(); } } #endif From 458a10ad382bc167906ec4bcdd7892ef358ad371 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:14:43 +0000 Subject: [PATCH 12/15] Move Start after CTS exchange; null-check callback in full-arg constructor Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index 47bfd93..663d559 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -103,11 +103,12 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) throw new ArgumentOutOfRangeException(nameof(period)); var cts = new CancellationTokenSource(); - Start(callback, state, dueTime, period, cts.Token); var oldCts = Interlocked.Exchange(ref _cts, cts); oldCts?.Cancel(); oldCts?.Dispose(); + Start(callback, state, dueTime, period, cts.Token); + // Handle race where Dispose completes after the initial _disposed check // but before/just after the exchange: ensure the newly created CTS // is also cancelled and disposed so it doesn't leak or keep firing. From 5b71532d27990f94f3879762e888824870559935 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Thu, 19 Feb 2026 23:30:48 +0200 Subject: [PATCH 13/15] Update PolyShim/NetCore10/Timer.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index 663d559..bd948db 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -103,11 +103,12 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) throw new ArgumentOutOfRangeException(nameof(period)); var cts = new CancellationTokenSource(); + var token = cts.Token; var oldCts = Interlocked.Exchange(ref _cts, cts); oldCts?.Cancel(); oldCts?.Dispose(); - Start(callback, state, dueTime, period, cts.Token); + Start(callback, state, dueTime, period, token); // Handle race where Dispose completes after the initial _disposed check // but before/just after the exchange: ensure the newly created CTS From 9a54c8b7c598896a37514c07a65d8b6454df8133 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:36:10 +0000 Subject: [PATCH 14/15] Guard cts.Cancel() against ObjectDisposedException in Dispose/Schedule race recheck Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index bd948db..b786096 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -115,7 +115,12 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) // is also cancelled and disposed so it doesn't leak or keep firing. if (_disposed) { - cts.Cancel(); + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) { } + cts.Dispose(); } } From 03a32ae375bfc211ef413761a29dec4fb4b080ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:38:40 +0000 Subject: [PATCH 15/15] Guard oldCts.Cancel() against ObjectDisposedException in Schedule Co-authored-by: Tyrrrz <1935960+Tyrrrz@users.noreply.github.com> --- PolyShim/NetCore10/Timer.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/PolyShim/NetCore10/Timer.cs b/PolyShim/NetCore10/Timer.cs index b786096..d1a5b3e 100644 --- a/PolyShim/NetCore10/Timer.cs +++ b/PolyShim/NetCore10/Timer.cs @@ -105,7 +105,13 @@ private void Schedule(TimeSpan dueTime, TimeSpan period) var cts = new CancellationTokenSource(); var token = cts.Token; var oldCts = Interlocked.Exchange(ref _cts, cts); - oldCts?.Cancel(); + + try + { + oldCts?.Cancel(); + } + catch (ObjectDisposedException) { } + oldCts?.Dispose(); Start(callback, state, dueTime, period, token);