Describe the bug
When eager refresh invokes a factory delegate that throws before returning its Task, the memory lock for that key is not released.
After removing the cached entry, a subsequent GetOrSetAsync call with a healthy factory waits for the retained lock until its cancellation token fires. Returning a faulted Task from the eager-refresh factory instead does not cause the problem.
Requires only the .NET 10 SDK. No Redis, Docker, distributed cache, or custom test doubles are needed.
Save these two files in an empty directory.
Repro.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ZiggyCreatures.FusionCache" Version="2.8.0" />
</ItemGroup>
</Project>
Program.cs:
using ZiggyCreatures.Caching.Fusion;
using var cache = new FusionCache(new FusionCacheOptions());
var key = $"eager-refresh-{Guid.NewGuid():N}";
var options = new FusionCacheEntryOptions
{
Duration = TimeSpan.FromSeconds(30),
EagerRefreshThreshold = 0.01f
};
await cache.SetAsync(key, 1, options);
await Task.Delay(500); // Past the eager threshold (300 ms), before expiration.
var invoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await cache.GetOrSetAsync<int>(key, (_, _) =>
{
invoked.SetResult();
// Run with --control to return a faulted Task instead of throwing directly.
if (args.Contains("--control"))
return Task.FromException<int>(new InvalidOperationException("Factory failed"));
throw new InvalidOperationException("Factory failed");
}, options: options);
await invoked.Task.WaitAsync(TimeSpan.FromSeconds(5));
await cache.RemoveAsync(key);
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(2));
try
{
int value = await cache.GetOrSetAsync<int>(key, (_, _) => Task.FromResult(2),
token: cancellation.Token);
Console.WriteLine($"Healthy factory returned: {value} (expected 2)");
Environment.ExitCode = value == 2 ? 0 : 1;
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
Console.WriteLine("Healthy factory blocked until cancellation (expected value 2)");
Environment.ExitCode = 1;
}
Run:
dotnet run --project Repro.csproj
Observed output:
Healthy factory blocked until cancellation (expected value 2)
The reproduction exits with code 1 when it detects the bug. The same program should exit with code 0 once the behavior is corrected.
Run the control:
dotnet run --project Repro.csproj -- --control
The control changes only the eager-refresh failure: it returns Task.FromException<int>(...) instead of throwing before returning a task.
Observed control output, exit code 0:
Healthy factory returned: 2 (expected 2)
Expected behavior
A failed eager-refresh factory should release the memory lock regardless of whether the delegate throws immediately or returns a faulted task.
After the entry is removed, the healthy factory should execute and return 2 without reaching the cancellation deadline.
Versions
- FusionCache: 2.8.0, official unmodified package.
- Redis distributed locker: 2.8.0.
- .NET: 10.0.
- Host OS: macOS 26.6.2.
- Redis: redis:7-alpine, running locally in Docker.
- Integration tests use Redis Testcontainers.
Describe the bug
When eager refresh invokes a factory delegate that throws before returning its
Task, the memory lock for that key is not released.After removing the cached entry, a subsequent
GetOrSetAsynccall with a healthy factory waits for the retained lock until its cancellation token fires. Returning a faultedTaskfrom the eager-refresh factory instead does not cause the problem.Requires only the .NET 10 SDK. No Redis, Docker, distributed cache, or custom test doubles are needed.
Save these two files in an empty directory.
Repro.csproj:Program.cs:Run:
Observed output:
The reproduction exits with code
1when it detects the bug. The same program should exit with code0once the behavior is corrected.Run the control:
The control changes only the eager-refresh failure: it returns
Task.FromException<int>(...)instead of throwing before returning a task.Observed control output, exit code
0:Expected behavior
A failed eager-refresh factory should release the memory lock regardless of whether the delegate throws immediately or returns a faulted task.
After the entry is removed, the healthy factory should execute and return
2without reaching the cancellation deadline.Versions