Skip to content

Commit d28efeb

Browse files
committed
test: add rollback limit test for AdaptiveSemaphore on cancellation
1 parent be14a38 commit d28efeb

2 files changed

Lines changed: 56 additions & 8 deletions

File tree

src/MADE.Threading/AdaptiveSemaphore.cs

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ namespace MADE.Threading;
2020
/// }
2121
///
2222
/// // Reduce concurrency on backpressure.
23-
/// semaphore.TryShrink();
23+
/// await semaphore.TryShrinkAsync();
2424
/// </code>
2525
/// </remarks>
2626
public sealed class AdaptiveSemaphore : IDisposable
@@ -96,7 +96,20 @@ public async Task<int> TryShrinkAsync(CancellationToken cancellationToken = defa
9696
this.limit--;
9797
}
9898

99-
await this.semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
99+
try
100+
{
101+
await this.semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
102+
}
103+
catch
104+
{
105+
lock (this.adjustLock)
106+
{
107+
this.limit++;
108+
}
109+
110+
throw;
111+
}
112+
100113
return this.limit;
101114
}
102115

@@ -122,7 +135,17 @@ public int TryGrow()
122135
}
123136

124137
this.limit++;
125-
this.semaphore.Release();
138+
139+
try
140+
{
141+
this.semaphore.Release();
142+
}
143+
catch
144+
{
145+
this.limit--;
146+
throw;
147+
}
148+
126149
return this.limit;
127150
}
128151
}
@@ -156,13 +179,16 @@ public IDisposable Wait(CancellationToken cancellationToken = default)
156179
/// <inheritdoc />
157180
public void Dispose()
158181
{
159-
if (this.disposed)
182+
lock (this.adjustLock)
160183
{
161-
return;
162-
}
184+
if (this.disposed)
185+
{
186+
return;
187+
}
163188

164-
this.semaphore.Dispose();
165-
this.disposed = true;
189+
this.semaphore.Dispose();
190+
this.disposed = true;
191+
}
166192
}
167193

168194
private sealed class SemaphoreReleaser : IDisposable

tests/MADE.Threading.Tests/Tests/AdaptiveSemaphoreTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,28 @@ public async Task ShouldReduceAvailablePermits()
151151
semaphore.Limit.ShouldBe(1);
152152
semaphore.Available.ShouldBe(1);
153153
}
154+
155+
[Test]
156+
public async Task ShouldRollBackLimitWhenCancelled()
157+
{
158+
// Arrange
159+
using var semaphore = new AdaptiveSemaphore(initial: 1, minimum: 1, maximum: 2);
160+
semaphore.TryGrow(); // limit = 2, available = 2
161+
162+
// Exhaust both permits so the next WaitAsync will block.
163+
using var hold1 = await semaphore.WaitAsync();
164+
using var hold2 = await semaphore.WaitAsync();
165+
166+
using var cts = new CancellationTokenSource();
167+
cts.Cancel();
168+
169+
// Act - shrink should decrement limit then fail to acquire, rolling back.
170+
await Should.ThrowAsync<OperationCanceledException>(
171+
async () => await semaphore.TryShrinkAsync(cts.Token));
172+
173+
// Assert - limit should be restored to 2.
174+
semaphore.Limit.ShouldBe(2);
175+
}
154176
}
155177

156178
public class WhenGrowing

0 commit comments

Comments
 (0)