Skip to content

Avoid unobserved task exception in AsyncAtomicFactory #686

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 15, 2025
Merged
48 changes: 48 additions & 0 deletions BitFaster.Caching.UnitTests/Atomic/AsyncAtomicFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
using BitFaster.Caching.Atomic;
using FluentAssertions;
using Xunit;
using Xunit.Abstractions;

namespace BitFaster.Caching.UnitTests.Atomic
{
public class AsyncAtomicFactoryTests
{
private readonly ITestOutputHelper outputHelper;

public AsyncAtomicFactoryTests(ITestOutputHelper outputHelper)
{
this.outputHelper = outputHelper;
}

[Fact]
public void DefaultCtorValueIsNotCreated()
{
Expand Down Expand Up @@ -156,6 +164,46 @@ await Task.WhenAll(first, second)
}
}

[Fact]
public async Task WhenValueCreateThrowsDoesNotCauseUnobservedTaskException()
{
bool unobservedExceptionThrown = false;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;

try
{
await AsyncAtomicFactoryGetValueAsync();

GC.Collect();
GC.WaitForPendingFinalizers();
}
finally
{
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
}

unobservedExceptionThrown.Should().BeFalse();

void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
{
outputHelper.WriteLine($"Unobserved task exception {e.Exception}");
unobservedExceptionThrown = true;
e.SetObserved();
}

static async Task AsyncAtomicFactoryGetValueAsync()
{
var a = new AsyncAtomicFactory<int, int>();
try
{
_ = await a.GetValueAsync(12, i => throw new ArithmeticException());
}
catch (ArithmeticException)
{
}
}
}

[Fact]
public void WhenValueNotCreatedHashCodeIsZero()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@
using BitFaster.Caching.Atomic;
using FluentAssertions;
using Xunit;
using Xunit.Abstractions;

namespace BitFaster.Caching.UnitTests.Atomic
{
public class ScopedAsyncAtomicFactoryTests
{
private readonly ITestOutputHelper outputHelper;

public ScopedAsyncAtomicFactoryTests(ITestOutputHelper outputHelper)
{
this.outputHelper = outputHelper;
}

[Fact]
public void WhenScopeIsNotCreatedScopeIfCreatedReturnsNull()
{
Expand Down Expand Up @@ -105,7 +113,7 @@ public void WhenValueIsCreatedDisposeDisposesValue()
{
var holder = new IntHolder() { actualNumber = 2 };
var atomicFactory = new ScopedAsyncAtomicFactory<int, IntHolder>(holder);

atomicFactory.Dispose();

holder.disposed.Should().BeTrue();
Expand Down Expand Up @@ -152,11 +160,10 @@ public async Task WhenCallersRunConcurrentlyResultIsFromWinner()

result1.l.Value.actualNumber.Should().Be(winningNumber);
result2.l.Value.actualNumber.Should().Be(winningNumber);

winnerCount.Should().Be(1);
}


[Fact]
public async Task WhenCallersRunConcurrentlyWithFailureSameExceptionIsPropagated()
{
Expand Down Expand Up @@ -199,6 +206,49 @@ await Task.WhenAll(first, second)
}
}

[Fact]
public async Task WhenCreateFromFactoryLifetimeThrowsDoesNotCauseUnobservedTaskException()
{
bool unobservedExceptionThrown = false;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;

try
{
await ScopedAsyncAtomicFactoryTryCreateLifetimeAsync();

GC.Collect();
GC.WaitForPendingFinalizers();
}
finally
{
TaskScheduler.UnobservedTaskException -= OnUnobservedTaskException;
}

unobservedExceptionThrown.Should().BeFalse();

void OnUnobservedTaskException(object sender, UnobservedTaskExceptionEventArgs e)
{
outputHelper.WriteLine($"Unobserved task exception {e.Exception}");
unobservedExceptionThrown = true;
e.SetObserved();
}

static async Task ScopedAsyncAtomicFactoryTryCreateLifetimeAsync()
{
var a = new ScopedAsyncAtomicFactory<int, IntHolder>();
try
{
_ = await a.TryCreateLifetimeAsync(1, k =>
{
throw new ArithmeticException();
});
}
catch (ArithmeticException)
{
}
}
}

[Fact]
public async Task WhenDisposedWhileInitResultIsDisposed()
{
Expand Down
5 changes: 4 additions & 1 deletion BitFaster.Caching/Atomic/AsyncAtomicFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ public async ValueTask<V> CreateValueAsync<TFactory>(K key, TFactory valueFactor
{
Volatile.Write(ref isInitialized, false);
tcs.SetException(ex);
throw;

// always await the task to avoid unobserved task exceptions - normal case is that no other thread is waiting.
// this will re-throw the exception.
await tcs.Task.ConfigureAwait(false);
}
}

Expand Down
5 changes: 4 additions & 1 deletion BitFaster.Caching/Atomic/ScopedAsyncAtomicFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ public async ValueTask<Scoped<V>> CreateScopeAsync<TFactory>(K key, TFactory val
{
Volatile.Write(ref isTaskInitialized, false);
tcs.SetException(ex);
throw;

// always await the task to avoid unobserved task exceptions - normal case is that no other thread is waiting.
// this will re-throw the exception.
await tcs.Task.ConfigureAwait(false);
}
}

Expand Down
Loading