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);