Skip to content

Commit f1da596

Browse files
authored
Breaking: Drop netstandard2.0; fix scheduling race conditions and improve cancellation fast paths (#437)
1 parent 5723c3e commit f1da596

40 files changed

+7271
-967
lines changed

.editorconfig

Lines changed: 691 additions & 367 deletions
Large diffs are not rendered by default.

.github/copilot-instructions.md

Lines changed: 448 additions & 0 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 217 additions & 33 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 473 additions & 0 deletions
Large diffs are not rendered by default.

codecov.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
### YamlMime:ManagedReference
2+
ignore:
3+
- src/tests
4+
- integrationtests
5+
- benchmarks
6+
- "**/Tests/"
7+
- "**/*.Tests/"

src/Directory.Build.props

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
<RepositoryUrl>https://github.com/reactiveui/punchclock</RepositoryUrl>
1414
<RepositoryType>git</RepositoryType>
1515
<GenerateDocumentationFile>true</GenerateDocumentationFile>
16-
<CodeAnalysisRuleSet>$(MSBuildThisFileDirectory)/analyzers.ruleset</CodeAnalysisRuleSet>
1716
<IsTestProject>$(MSBuildProjectName.Contains('Tests'))</IsTestProject>
1817
<DebugType>Embedded</DebugType>
1918
<!-- Optional: Publish the repository URL in the built .nupkg (in the NuSpec <Repository> element) -->
@@ -22,15 +21,27 @@
2221
<EmbedUntrackedSources>true</EmbedUntrackedSources>
2322
<!-- Optional: Include PDB in the built .nupkg -->
2423
<AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder>
25-
24+
<WarningsAsErrors>$(WarningsAsErrors);nullable</WarningsAsErrors>
25+
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
2626
<EnableNETAnalyzers>True</EnableNETAnalyzers>
2727
<AnalysisLevel>latest</AnalysisLevel>
28+
<ImplicitUsings>enable</ImplicitUsings>
29+
<Nullable>enable</Nullable>
30+
<LangVersion>latest</LangVersion>
2831
</PropertyGroup>
2932

3033
<PropertyGroup Condition="$(IsTestProject)">
3134
<IsPackable>false</IsPackable>
3235
</PropertyGroup>
3336

37+
<!-- MTP Native JSON Configuration -->
38+
<ItemGroup Condition="$(IsTestProject)">
39+
<None Include="$(MSBuildThisFileDirectory)testconfig.json">
40+
<Link>$(AssemblyName).testconfig.json</Link>
41+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
42+
</None>
43+
</ItemGroup>
44+
3445
<ItemGroup Condition="'$(IsTestProject)' != 'true'">
3546
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" />
3647
</ItemGroup>
@@ -53,4 +64,13 @@
5364
<ItemGroup>
5465
<AdditionalFiles Include="$(MSBuildThisFileDirectory)/stylecop.json" Link="stylecop.json" />
5566
</ItemGroup>
67+
68+
<PropertyGroup>
69+
<PunchclockModernTargets>net8.0;net9.0;net10.0</PunchclockModernTargets>
70+
71+
<PunchclockLegacyTargets Condition="$([MSBuild]::IsOsPlatform('Windows'))">net462;net472;net481</PunchclockLegacyTargets>
72+
73+
<PunchclockCoreTargets>$(PunchclockModernTargets)</PunchclockCoreTargets>
74+
<PunchclockCoreTargets Condition=" '$(PunchclockLegacyTargets)' != '' ">$(PunchclockCoreTargets);$(PunchclockLegacyTargets)</PunchclockCoreTargets>
75+
</PropertyGroup>
5676
</Project>

src/Directory.Packages.props

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,18 @@
55

66
<ItemGroup>
77
<PackageVersion Include="System.Reactive" Version="6.1.0" />
8+
<PackageVersion Include="Microsoft.Reactive.Testing" Version="6.1.0" />
89
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
910
<PackageVersion Include="Nerdbank.GitVersioning" Version="3.9.50" />
1011
<PackageVersion Include="stylecop.analyzers" Version="1.2.0-beta.556" />
1112
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
13+
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.1.0" />
1214

1315
<PackageVersion Include="DynamicData" Version="9.4.1" />
1416
<PackageVersion Include="PublicApiGenerator" Version="11.5.4" />
15-
<PackageVersion Include="Verify.NUnit" Version="31.9.0" />
16-
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
17-
<PackageVersion Include="NUnit" Version="4.4.0" />
18-
<PackageVersion Include="NUnit3TestAdapter" Version="6.0.1" />
19-
<PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
20-
<PackageVersion Include="Shouldly" Version="4.3.0" />
17+
<PackageVersion Include="Verify.TUnit" Version="31.9.3" />
18+
<PackageVersion Include="TUnit" Version="1.9.26" />
2119
<PackageVersion Include="DiffEngine" Version="18.2.0" />
22-
<PackageVersion Include="coverlet.msbuild" Version="6.0.4" />
20+
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
2321
</ItemGroup>
2422
</Project>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
root = true
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2025 ReactiveUI and Contributors. All rights reserved.
2+
// Licensed to the ReactiveUI and Contributors under one or more agreements.
3+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using BenchmarkDotNet.Attributes;
9+
using BenchmarkDotNet.Jobs;
10+
11+
namespace Punchclock.Benchmarks;
12+
13+
/// <summary>
14+
/// Benchmarks for CancellationToken fast path optimizations.
15+
/// Compares old vs new behavior when enqueueing with various token types.
16+
/// </summary>
17+
[MemoryDiagnoser]
18+
[SimpleJob(RuntimeMoniker.Net462, warmupCount: 3, iterationCount: 10)]
19+
[SimpleJob(RuntimeMoniker.Net80, warmupCount: 3, iterationCount: 10)]
20+
[SimpleJob(RuntimeMoniker.Net10_0, warmupCount: 3, iterationCount: 10)]
21+
[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0), warmupCount: 3, iterationCount: 10)]
22+
[MarkdownExporterAttribute.GitHub]
23+
public class CancellationTokenBenchmarks
24+
{
25+
private OperationQueue? _queue;
26+
27+
/// <summary>
28+
/// Setup method called before each benchmark.
29+
/// </summary>
30+
[GlobalSetup]
31+
public void Setup()
32+
{
33+
_queue = new OperationQueue(maximumConcurrent: 4);
34+
}
35+
36+
/// <summary>
37+
/// Cleanup method called after each benchmark.
38+
/// </summary>
39+
[GlobalCleanup]
40+
public void Cleanup()
41+
{
42+
_queue?.Dispose();
43+
}
44+
45+
/// <summary>
46+
/// Benchmark: Enqueue with CancellationToken.None (fast path).
47+
/// New version should have zero allocations, old version allocates Observable.Create + delegate.
48+
/// </summary>
49+
/// <returns>Task for async operation.</returns>
50+
[Benchmark(Description = "Enqueue with CancellationToken.None")]
51+
public async Task EnqueueWithTokenNone()
52+
{
53+
var result = await _queue!.Enqueue(
54+
priority: 1,
55+
key: "bench",
56+
asyncOperation: () => Task.FromResult(42),
57+
token: CancellationToken.None);
58+
}
59+
60+
/// <summary>
61+
/// Benchmark: Enqueue with a cancellable token (slow path - unavoidable).
62+
/// Both versions should perform similarly.
63+
/// </summary>
64+
/// <returns>Task for async operation.</returns>
65+
[Benchmark(Description = "Enqueue with cancellable token")]
66+
public async Task EnqueueWithCancellableToken()
67+
{
68+
using var cts = new CancellationTokenSource();
69+
var result = await _queue!.Enqueue(
70+
priority: 1,
71+
key: "bench",
72+
asyncOperation: () => Task.FromResult(42),
73+
token: cts.Token);
74+
}
75+
76+
/// <summary>
77+
/// Benchmark: Enqueue without any token (no cancellation support).
78+
/// Baseline for comparison.
79+
/// </summary>
80+
/// <returns>Task for async operation.</returns>
81+
[Benchmark(Baseline = true, Description = "Enqueue without token (baseline)")]
82+
public async Task EnqueueWithoutToken()
83+
{
84+
var result = await _queue!.Enqueue(
85+
priority: 1,
86+
key: "bench",
87+
asyncOperation: () => Task.FromResult(42));
88+
}
89+
90+
/// <summary>
91+
/// Benchmark: Batch enqueue 100 operations with CancellationToken.None.
92+
/// Tests fast path scaling.
93+
/// </summary>
94+
/// <returns>Task for async operation.</returns>
95+
[Benchmark(Description = "Batch 100 operations with CancellationToken.None")]
96+
public async Task BatchEnqueueWithTokenNone()
97+
{
98+
var tasks = new Task<int>[100];
99+
for (var i = 0; i < 100; i++)
100+
{
101+
var capturedI = i;
102+
tasks[i] = _queue!.Enqueue(
103+
priority: 1,
104+
key: $"bench{capturedI}",
105+
asyncOperation: () => Task.FromResult(capturedI),
106+
token: CancellationToken.None);
107+
}
108+
109+
await Task.WhenAll(tasks);
110+
}
111+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// Copyright (c) 2025 ReactiveUI and Contributors. All rights reserved.
2+
// Licensed to the ReactiveUI and Contributors under one or more agreements.
3+
// ReactiveUI and Contributors licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Reactive.Linq;
7+
using System.Reactive.Threading.Tasks;
8+
using System.Threading.Tasks;
9+
using BenchmarkDotNet.Attributes;
10+
using BenchmarkDotNet.Jobs;
11+
12+
namespace Punchclock.Benchmarks;
13+
14+
/// <summary>
15+
/// Benchmarks for core OperationQueue scenarios.
16+
/// Tests priority ordering, key-based serialization, and concurrent execution.
17+
/// </summary>
18+
[MemoryDiagnoser]
19+
[SimpleJob(RuntimeMoniker.Net462, warmupCount: 3, iterationCount: 10)]
20+
[SimpleJob(RuntimeMoniker.Net80, warmupCount: 3, iterationCount: 10)]
21+
[SimpleJob(RuntimeMoniker.Net10_0, warmupCount: 3, iterationCount: 10)]
22+
[SimpleJob(RuntimeMoniker.NativeAot10_0, id: nameof(RuntimeMoniker.NativeAot10_0), warmupCount: 3, iterationCount: 10)]
23+
[MarkdownExporterAttribute.GitHub]
24+
public class OperationQueueBenchmarks
25+
{
26+
private OperationQueue? _queue;
27+
28+
/// <summary>
29+
/// Setup method called before each benchmark.
30+
/// </summary>
31+
[GlobalSetup]
32+
public void Setup()
33+
{
34+
_queue = new OperationQueue(maximumConcurrent: 4);
35+
}
36+
37+
/// <summary>
38+
/// Cleanup method called after each benchmark.
39+
/// </summary>
40+
[GlobalCleanup]
41+
public void Cleanup()
42+
{
43+
_queue?.Dispose();
44+
}
45+
46+
/// <summary>
47+
/// Benchmark: Enqueue and execute 100 operations with varying priorities.
48+
/// Tests priority queue ordering performance.
49+
/// </summary>
50+
/// <returns>Task for async operation.</returns>
51+
[Benchmark(Description = "100 operations with mixed priorities")]
52+
public async Task MixedPriorities()
53+
{
54+
var tasks = new Task<int>[100];
55+
for (var i = 0; i < 100; i++)
56+
{
57+
var capturedI = i;
58+
var priority = capturedI % 10; // Priorities 0-9
59+
tasks[i] = _queue!.Enqueue(
60+
priority: priority,
61+
asyncOperation: () => Task.FromResult(capturedI));
62+
}
63+
64+
await Task.WhenAll(tasks);
65+
}
66+
67+
/// <summary>
68+
/// Benchmark: Enqueue 50 operations with the same key (serialized execution).
69+
/// Tests key-based serialization performance.
70+
/// </summary>
71+
/// <returns>Task for async operation.</returns>
72+
[Benchmark(Description = "50 serialized operations (same key)")]
73+
public async Task SerializedOperations()
74+
{
75+
var tasks = new Task<int>[50];
76+
for (var i = 0; i < 50; i++)
77+
{
78+
var capturedI = i;
79+
tasks[i] = _queue!.Enqueue(
80+
priority: 1,
81+
key: "serial",
82+
asyncOperation: () => Task.FromResult(capturedI));
83+
}
84+
85+
await Task.WhenAll(tasks);
86+
}
87+
88+
/// <summary>
89+
/// Benchmark: Enqueue 100 operations with unique keys (parallel execution).
90+
/// Tests concurrent execution performance.
91+
/// </summary>
92+
/// <returns>Task for async operation.</returns>
93+
[Benchmark(Baseline = true, Description = "100 parallel operations (unique keys)")]
94+
public async Task ParallelOperations()
95+
{
96+
var tasks = new Task<int>[100];
97+
for (var i = 0; i < 100; i++)
98+
{
99+
var capturedI = i;
100+
tasks[i] = _queue!.Enqueue(
101+
priority: 1,
102+
key: $"key{capturedI}",
103+
asyncOperation: () => Task.FromResult(capturedI));
104+
}
105+
106+
await Task.WhenAll(tasks);
107+
}
108+
109+
/// <summary>
110+
/// Benchmark: Observable-based enqueue (10 operations).
111+
/// Tests the raw observable API performance.
112+
/// </summary>
113+
/// <returns>Task for async operation.</returns>
114+
[Benchmark(Description = "10 observable operations")]
115+
public async Task ObservableOperations()
116+
{
117+
var tasks = new Task<int>[10];
118+
for (var i = 0; i < 10; i++)
119+
{
120+
var capturedI = i;
121+
var obs = _queue!.EnqueueObservableOperation(
122+
priority: 1,
123+
asyncCalculationFunc: () => Observable.Return(capturedI));
124+
tasks[i] = obs.ToTask();
125+
}
126+
127+
await Task.WhenAll(tasks);
128+
}
129+
130+
/// <summary>
131+
/// Benchmark: Pause and resume queue with pending operations.
132+
/// Tests pause/resume overhead and ref-counting.
133+
/// </summary>
134+
/// <returns>Task for async operation.</returns>
135+
[Benchmark(Description = "Pause/resume with 20 operations")]
136+
public async Task PauseResumeOperations()
137+
{
138+
using var pause = _queue!.PauseQueue();
139+
140+
var tasks = new Task<int>[20];
141+
for (var i = 0; i < 20; i++)
142+
{
143+
var capturedI = i;
144+
tasks[i] = _queue.Enqueue(
145+
priority: 1,
146+
asyncOperation: () => Task.FromResult(capturedI));
147+
}
148+
149+
// Resume by disposing pause
150+
pause.Dispose();
151+
152+
await Task.WhenAll(tasks);
153+
}
154+
155+
/// <summary>
156+
/// Benchmark: Random priority with tie-breaking (deterministic seed).
157+
/// Tests randomization overhead.
158+
/// </summary>
159+
/// <returns>Task for async operation.</returns>
160+
[Benchmark(Description = "50 operations with random tie-breaking")]
161+
public async Task RandomPriorityTieBreaking()
162+
{
163+
using var randomQueue = new OperationQueue(
164+
maximumConcurrent: 4,
165+
randomizeEqualPriority: true,
166+
seed: 42);
167+
168+
var tasks = new Task<int>[50];
169+
for (var i = 0; i < 50; i++)
170+
{
171+
var capturedI = i;
172+
tasks[i] = randomQueue.Enqueue(
173+
priority: 1, // Same priority for all - triggers randomization
174+
asyncOperation: () => Task.FromResult(capturedI));
175+
}
176+
177+
await Task.WhenAll(tasks);
178+
}
179+
}

0 commit comments

Comments
 (0)