Skip to content

Commit 574863b

Browse files
committed
Never return from disposed caches
1 parent be3f94d commit 574863b

10 files changed

Lines changed: 182 additions & 3 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,15 @@ Notes:
274274
* Some of the classes implement `IDisposable` interface and should be correctly disposed.
275275
* Be cautious when caching types that implement `IDisposable` interface as the values will not be automatically disposed by the caches.
276276
* Be cautious when using classes with `LazyThreadSafetyMode.PublicationOnly` behavior together with `IDisposable` types as discarded instances will not be disposed.
277+
* `PublicationOnly` implementations may run multiple factories concurrently and publish the first successful result.
278+
* Async lazy and initializer methods throw `InvalidOperationException` if a factory returns a null `Task`.
277279

278280
#### Lazy
279281

280282
Two different implementations of async lazy values are available — `LazyAsyncPublicationOnly` and `LazyAsyncExecutionAndPublication`. Those are async versions of `System.Lazy` class with `LazyThreadSafetyMode.PublicationOnly` and `LazyThreadSafetyMode.ExecutionAndPublication` modes respectively. The reason that async lazy implementations are separate classes is that `LazyAsyncExecutionAndPublication` implements `IDisposable` due to its usage of an instance of `SemaphoreSlim` whereas `LazyAsyncPublicationOnly` does not need to implement `IDisposable`.
281283

284+
`LazyAsyncExecutionAndPublication` runs at most one in-flight factory and retries after failed or canceled attempts. `LazyAsyncPublicationOnly` may execute multiple concurrent factories, but only the first successfully published value is retained.
285+
282286
```csharp
283287
using LibSharp.Caching;
284288

@@ -309,6 +313,8 @@ Two different implementations of async lazy values are available — `LazyAsyncP
309313

310314
Initializers in LibSharp are equivalents of lazy types, with the only difference being that the value factory is provided at lazy initialization time instead of creation time. They also enable cases where different factories can be used to initialize the value, where only one will succeed at setting the value.
311315

316+
`InitializerAsyncExecutionAndPublication` runs at most one in-flight factory and retries after failed or canceled attempts. `InitializerAsyncPublicationOnly` may execute multiple concurrent factories, but only the first successfully published value is retained.
317+
312318
```csharp
313319
using LibSharp.Caching;
314320

src/LibSharp/Caching/IInitializerAsync.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ public interface IInitializerAsync<T>
2626
/// <returns>The value.</returns>
2727
/// <remarks>
2828
/// If the factory faults or is canceled, the value is not considered initialized and a later call may retry.
29+
/// Publication-only implementations may execute multiple factories concurrently, but only one successful result will be published.
2930
/// </remarks>
3031
/// <exception cref="ObjectDisposedException">Thrown if the initializer has been disposed.</exception>
3132
/// <exception cref="OperationCanceledException">Thrown if <paramref name="cancellationToken"/> is canceled before the value is produced.</exception>
33+
/// <exception cref="InvalidOperationException">Thrown if <paramref name="factory"/> returns a null task.</exception>
3234
Task<T> GetValueAsync(Func<CancellationToken, Task<T>> factory, CancellationToken cancellationToken = default);
3335
}

src/LibSharp/Caching/InitializerAsyncExecutionAndPublication.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ public async Task<T> GetValueAsync(Func<CancellationToken, Task<T>> factory, Can
4848
m_hasValue = true;
4949
}
5050

51+
ObjectDisposedException.ThrowIf(Volatile.Read(ref m_isDisposed) != 0, this);
52+
5153
return m_value;
5254
}
5355
}

src/LibSharp/Caching/InitializerAsyncPublicationOnly.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ namespace LibSharp.Caching;
1111
/// Async initializer with LazyThreadSafetyMode.PublicationOnly.
1212
/// </summary>
1313
/// <typeparam name="T">Value type.</typeparam>
14-
/// <remarks>Should not be used with IDisposable or IAsyncDisposable value types since it does not dispose of values.</remarks>
14+
/// <remarks>
15+
/// Should not be used with IDisposable or IAsyncDisposable value types since it does not dispose of values.
16+
/// Concurrent callers may execute different factories more than once; only the first successfully published value is retained and returned to all callers.
17+
/// Faulted or canceled attempts are not cached and may be retried by later callers.
18+
/// </remarks>
1519
public sealed class InitializerAsyncPublicationOnly<T> : IInitializerAsync<T>
1620
{
1721
/// <inheritdoc/>

src/LibSharp/Caching/LazyAsyncExecutionAndPublication.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ namespace LibSharp.Caching;
1212
/// Async lazy with LazyThreadSafetyMode.ExecutionAndPublication.
1313
/// </summary>
1414
/// <typeparam name="T">Value type.</typeparam>
15-
/// <remarks>Should not be used with IDisposable or IAsyncDisposable value types since it does not dispose of values.</remarks>
15+
/// <remarks>
16+
/// Should not be used with IDisposable or IAsyncDisposable value types since it does not dispose of values.
17+
/// A successful initialization is cached permanently. Faulted or canceled attempts are not cached and may be retried by later callers.
18+
/// </remarks>
1619
public sealed class LazyAsyncExecutionAndPublication<T> : IDisposable
1720
{
1821
/// <summary>
@@ -55,6 +58,9 @@ public bool HasValue
5558
/// </summary>
5659
/// <param name="cancellationToken">Cancellation token.</param>
5760
/// <returns>The value.</returns>
61+
/// <exception cref="ObjectDisposedException">Thrown if this instance has been disposed.</exception>
62+
/// <exception cref="OperationCanceledException">Thrown if <paramref name="cancellationToken"/> is canceled before the value is produced.</exception>
63+
/// <exception cref="InvalidOperationException">Thrown if the value factory returns a null task.</exception>
5864
public async Task<T> GetValueAsync(CancellationToken cancellationToken = default)
5965
{
6066
ObjectDisposedException.ThrowIf(Volatile.Read(ref m_isDisposed) != 0, this);
@@ -71,6 +77,8 @@ public async Task<T> GetValueAsync(CancellationToken cancellationToken = default
7177
m_hasValue = true;
7278
}
7379

80+
ObjectDisposedException.ThrowIf(Volatile.Read(ref m_isDisposed) != 0, this);
81+
7482
return m_value;
7583
}
7684
}

src/LibSharp/Caching/LazyAsyncPublicationOnly.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ namespace LibSharp.Caching;
1111
/// Async lazy with LazyThreadSafetyMode.PublicationOnly.
1212
/// </summary>
1313
/// <typeparam name="T">Value type.</typeparam>
14-
/// <remarks>Should not be used with IDisposable or IAsyncDisposable value types since it does not dispose of values.</remarks>
14+
/// <remarks>
15+
/// Should not be used with IDisposable or IAsyncDisposable value types since it does not dispose of values.
16+
/// Concurrent callers may execute the factory more than once; only the first successfully published value is retained and returned to all callers.
17+
/// Faulted or canceled attempts are not cached and may be retried by later callers.
18+
/// </remarks>
1519
public sealed class LazyAsyncPublicationOnly<T>
1620
{
1721
/// <summary>
@@ -44,6 +48,8 @@ public LazyAsyncPublicationOnly(Func<CancellationToken, Task<T>> factory)
4448
/// </summary>
4549
/// <param name="cancellationToken">Cancellation token.</param>
4650
/// <returns>The value.</returns>
51+
/// <exception cref="OperationCanceledException">Thrown if <paramref name="cancellationToken"/> is canceled before a published value is produced.</exception>
52+
/// <exception cref="InvalidOperationException">Thrown if the value factory returns a null task.</exception>
4753
public async Task<T> GetValueAsync(CancellationToken cancellationToken = default)
4854
{
4955
if (!HasValue)

test/LibSharp.UnitTests/Caching/InitializerAsyncExecutionAndPublicationUnitTests.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,4 +159,30 @@ public async Task GetValueAsync_FactoryReturningNullTask_ThrowsInvalidOperationE
159159
_ = await Assert.ThrowsExactlyAsync<InvalidOperationException>(async () =>
160160
await initializer.GetValueAsync(_ => null, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false);
161161
}
162+
163+
[TestMethod]
164+
public async Task GetValueAsync_DisposedWhileFactoryIsInFlight_ThrowsObjectDisposedException()
165+
{
166+
// Arrange
167+
using InitializerAsyncExecutionAndPublication<int> initializer = new InitializerAsyncExecutionAndPublication<int>();
168+
TaskCompletionSource<bool> factoryStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
169+
TaskCompletionSource<int> factoryTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
170+
171+
Task<int> getTask = initializer.GetValueAsync(
172+
cancellationToken =>
173+
{
174+
_ = factoryStarted.TrySetResult(true);
175+
return factoryTcs.Task;
176+
},
177+
CancellationToken.None);
178+
179+
_ = await factoryStarted.Task.WaitAsync(CancellationToken.None).ConfigureAwait(false);
180+
181+
// Act
182+
initializer.Dispose();
183+
factoryTcs.SetResult(42);
184+
185+
// Assert
186+
_ = await Assert.ThrowsExactlyAsync<ObjectDisposedException>(() => getTask).ConfigureAwait(false);
187+
}
162188
}

test/LibSharp.UnitTests/Caching/InitializerAsyncPublicationOnlyUnitTests.cs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,37 @@ public async Task GetValueAsync_FactoryReturningNullTask_ThrowsInvalidOperationE
5454
_ = await Assert.ThrowsExactlyAsync<InvalidOperationException>(async () =>
5555
await lazy.GetValueAsync(_ => null, CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false);
5656
}
57+
58+
[TestMethod]
59+
public async Task GetValueAsync_ConcurrentCallers_PublishSingleWinningValue()
60+
{
61+
// Arrange
62+
InitializerAsyncPublicationOnly<int> initializer = new InitializerAsyncPublicationOnly<int>();
63+
TaskCompletionSource<bool> bothFactoriesStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
64+
int executionCount = 0;
65+
66+
async Task<int> Factory(CancellationToken cancellationToken)
67+
{
68+
int count = Interlocked.Increment(ref executionCount);
69+
if (count == 2)
70+
{
71+
_ = bothFactoriesStarted.TrySetResult(true);
72+
}
73+
74+
_ = await bothFactoriesStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
75+
return count;
76+
}
77+
78+
// Act
79+
Task<int> first = Task.Run(() => initializer.GetValueAsync(Factory, CancellationToken.None), CancellationToken.None);
80+
Task<int> second = Task.Run(() => initializer.GetValueAsync(Factory, CancellationToken.None), CancellationToken.None);
81+
int[] results = await Task.WhenAll(first, second).ConfigureAwait(false);
82+
int published = await initializer.GetValueAsync(Factory, CancellationToken.None).ConfigureAwait(false);
83+
84+
// Assert
85+
Assert.AreEqual(2, executionCount);
86+
Assert.AreEqual(results[0], results[1]);
87+
Assert.AreEqual(results[0], published);
88+
Assert.IsTrue(initializer.HasValue);
89+
}
5790
}

test/LibSharp.UnitTests/Caching/LazyAsyncExecutionAndPublicationUnitTests.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,66 @@ public async Task FromValueFactory_TokenCanceled_Throws()
142142
}
143143
}
144144

145+
[TestMethod]
146+
public async Task GetValueAsync_ConcurrentCallers_OnlyOneFactoryExecutes()
147+
{
148+
// Arrange
149+
TaskCompletionSource<bool> factoryStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
150+
TaskCompletionSource<bool> releaseFactory = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
151+
int executionCount = 0;
152+
153+
using LazyAsyncExecutionAndPublication<int> lazy = new LazyAsyncExecutionAndPublication<int>(
154+
async cancellationToken =>
155+
{
156+
_ = Interlocked.Increment(ref executionCount);
157+
_ = factoryStarted.TrySetResult(true);
158+
_ = await releaseFactory.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
159+
return 42;
160+
});
161+
162+
Task<int>[] callers = new Task<int>[8];
163+
for (int i = 0; i < callers.Length; i++)
164+
{
165+
callers[i] = Task.Run(() => lazy.GetValueAsync(CancellationToken.None), CancellationToken.None);
166+
}
167+
168+
_ = await factoryStarted.Task.WaitAsync(CancellationToken.None).ConfigureAwait(false);
169+
Assert.AreEqual(1, executionCount);
170+
171+
// Act
172+
releaseFactory.SetResult(true);
173+
int[] results = await Task.WhenAll(callers).ConfigureAwait(false);
174+
175+
// Assert
176+
CollectionAssert.AreEqual(new[] { 42, 42, 42, 42, 42, 42, 42, 42 }, results);
177+
Assert.IsTrue(lazy.HasValue);
178+
}
179+
180+
[TestMethod]
181+
public async Task GetValueAsync_DisposedWhileFactoryIsInFlight_ThrowsObjectDisposedException()
182+
{
183+
// Arrange
184+
TaskCompletionSource<bool> factoryStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
185+
TaskCompletionSource<int> factoryTcs = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
186+
187+
using LazyAsyncExecutionAndPublication<int> lazy = new LazyAsyncExecutionAndPublication<int>(
188+
cancellationToken =>
189+
{
190+
_ = factoryStarted.TrySetResult(true);
191+
return factoryTcs.Task;
192+
});
193+
194+
Task<int> getTask = lazy.GetValueAsync(CancellationToken.None);
195+
_ = await factoryStarted.Task.WaitAsync(CancellationToken.None).ConfigureAwait(false);
196+
197+
// Act
198+
lazy.Dispose();
199+
factoryTcs.SetResult(42);
200+
201+
// Assert
202+
_ = await Assert.ThrowsExactlyAsync<ObjectDisposedException>(() => getTask).ConfigureAwait(false);
203+
}
204+
145205
[TestMethod]
146206
public async Task FromFactory_NullTask_ThrowsInvalidOperationException()
147207
{

test/LibSharp.UnitTests/Caching/LazyAsyncPublicationOnlyUnitTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,37 @@ public async Task FromFactory_NullTask_ThrowsInvalidOperationException()
106106
await lazy.GetValueAsync(CancellationToken.None).ConfigureAwait(false)).ConfigureAwait(false);
107107
}
108108

109+
[TestMethod]
110+
public async Task GetValueAsync_ConcurrentCallers_PublishSingleWinningValue()
111+
{
112+
// Arrange
113+
TaskCompletionSource<bool> bothFactoriesStarted = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
114+
int executionCount = 0;
115+
116+
LazyAsyncPublicationOnly<int> lazy = new LazyAsyncPublicationOnly<int>(async cancellationToken =>
117+
{
118+
int count = Interlocked.Increment(ref executionCount);
119+
if (count == 2)
120+
{
121+
_ = bothFactoriesStarted.TrySetResult(true);
122+
}
123+
124+
_ = await bothFactoriesStarted.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
125+
return count;
126+
});
127+
128+
// Act
129+
Task<int> first = Task.Run(() => lazy.GetValueAsync(CancellationToken.None), CancellationToken.None);
130+
Task<int> second = Task.Run(() => lazy.GetValueAsync(CancellationToken.None), CancellationToken.None);
131+
int[] results = await Task.WhenAll(first, second).ConfigureAwait(false);
132+
int published = await lazy.GetValueAsync(CancellationToken.None).ConfigureAwait(false);
133+
134+
// Assert
135+
Assert.AreEqual(2, executionCount);
136+
Assert.AreEqual(results[0], results[1]);
137+
Assert.AreEqual(results[0], published);
138+
Assert.IsTrue(lazy.HasValue);
139+
}
140+
109141
public TestContext TestContext { get; set; }
110142
}

0 commit comments

Comments
 (0)