Repository navigation
DisposeComponentsAsync() can dispose nothing while a timer is rendering #1939
Description
Describe the bug
If a test calls DisposeComponentsAsync() (or BunitRenderer.DisposeComponents()) while a component's timer has InvokeAsync work running or queued on the renderer's dispatcher, the call can dispose nothing. The returned task still completes successfully.
The component stays alive, and its timer keeps firing, until the BunitContext itself is disposed. Calling DisposeComponentsAsync() again doesn't dispose it either.
The component below is based on Microsoft Learn's CounterState2 timer sample. I reduced its markup to the count line, set the timer to 1 ms and added an IsDisposed flag. The test calls DisposeComponentsAsync() from the test method, as the sample on bUnit's dispose components page does. Not every call fails, so the test disposes 100 times and counts the calls that leave their component undisposed.
I hit this in the test suite of Formidable, but only about once in a hundred full-suite runs. There, a test disposed a form while the form's timer could have its InvokeAsync work running on the dispatcher.
To reproduce:
- Run
dotnet new xunit -o BunitRepro -f net10.0, thencd BunitRepro. - In
BunitRepro.csproj, changeSdk="Microsoft.NET.Sdk"toSdk="Microsoft.NET.Sdk.Razor", and deleteUnitTest1.cs. - Run
dotnet add package bunit --version 2.11.3. - Add
FastCounter.razorandDisposeComponentsTimerTest.csfrom below. - Run
dotnet test.
Example:
Testing this component (FastCounter.razor):
@using System.Timers
@implements IDisposable
<p>Current count: @currentCount</p>
@code {
private int currentCount = 0;
private Timer timer = new(1);
public bool IsDisposed { get; private set; }
protected override void OnInitialized()
{
timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
timer.Start();
}
private void OnTimerCallback()
{
_ = InvokeAsync(() =>
{
currentCount++;
StateHasChanged();
});
}
public void Dispose()
{
IsDisposed = true;
timer.Dispose();
}
}With this test (DisposeComponentsTimerTest.cs):
using Bunit;
namespace BunitRepro;
public class DisposeComponentsTimerTest
{
[Fact]
public async Task DisposeComponentsAsync_while_a_timer_renders()
{
var lost = 0;
for (var i = 0; i < 100; i++)
{
await using var ctx = new BunitContext();
var cut = ctx.Render<FastCounter>();
var component = cut.Instance;
await Task.Delay(Random.Shared.Next(1, 20)); // let the timer run
await ctx.DisposeComponentsAsync();
if (!component.IsDisposed)
lost++;
}
Assert.Equal(0, lost); // fails: lost is above 0
}
}Results in this output:
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 13
It failed in 20 of 20 runs on my machine, with 4 to 21 of the 100 calls in each run leaving their component undisposed.
Expected behavior:
When the task returned by DisposeComponentsAsync() completes, every component rendered before the call has been disposed, whatever a timer was doing on the dispatcher at the time.
Version info:
- bUnit version: 2.11.3 (the code quoted below is unchanged on
mainat 0b9d00c) - .NET Runtime and Blazor version: .NET 10.0.13 (SDK 10.0.402); Microsoft.AspNetCore.Components 10.0.10, the version bUnit 2.11.3 brings in
- OS type and version: Windows 11 Pro 25H2 (10.0.26200), x64
- Test framework: xUnit 2.9.3
Additional context:
Here is why I think it happens, from reading BunitRenderer.DisposeComponents() in src/bunit/Rendering/BunitRenderer.cs at v2.11.3:
lock (renderTreeUpdateLock)
{
returnTask = Dispatcher.InvokeAsync(async () =>
{
ResetUnhandledException();
foreach (var root in rootComponents)
{
root.Detach();
}
await Task.WhenAll(disposalTasks).ConfigureAwait(false);
disposalTasks.Clear();
});
rootComponents.Clear();
}When the dispatcher is free, InvokeAsync runs the lambda inline, so the detach loop runs before rootComponents.Clear(). When the timer's work is running or queued there, the lambda is queued instead. Clear() then runs first, on the calling thread, and the lambda later loops over an empty list.
I confirmed this by reading rootComponents through reflection before and after each of 300 disposals. In all 39 that failed, the list went from one entry to none during the call, and the returned task was still pending. In all 261 that worked, the task had already completed when the call returned, so the lambda had run inline.
A fix might move the Clear() into the lambda: copy rootComponents, clear the list, then detach the roots in the copy. Render already adds to rootComponents on the dispatcher, so the list would then only be touched there. I haven't built or tested that. #1754 (closed without merging) made a different change: it moved this Clear() into the lambda, after the await. I haven't tested that either.
The workaround is to call DisposeComponentsAsync() from inside the dispatcher: await cut.InvokeAsync(() => ctx.DisposeComponentsAsync());. Blazor's dispatcher runs a nested InvokeAsync inline when the caller is already on it, so the detach runs before the Clear(). With that one change, the test passed in 20 of 20 runs.
Activity
linkdotnet commentedon Oct 16, 2026
Yeah that is an issue - while I barely use Timers, it is good to uncover those.
I will make a PR for that with some version of your proposed fix.
1754 wouldn't have helped here. Would have just shifted the problem.
fix: DisposeComponentsAsync disposes components while the dispatcher …
fix: DisposeComponentsAsync disposes components while the dispatcher …
fix: DisposeComponentsAsync disposes components while the dispatcher …
fix: DisposeComponentsAsync disposes components while the dispatcher …
hediasjarno commentedon Oct 16, 2026
We can reproduce this on bUnit 2.9.0 and 2.11.3, and it is not limited to timers.
We hit the same symptom in a large test suite under parallel load. The component that failed had no timer. Its
DisposeAsyncawaited a JS module release, and other async work was still queued on the renderer's dispatcher when the test disposed. The component stayed alive, and the call still returned success.Deterministic repro, no timer. A work item holds the dispatcher until the test releases it:
Probeis a plainComponentBasethat setsIsDisposedinDispose():This fails every time on 2.9.0 and on 2.11.3.
Probabilistic repro, no timer. The component starts a
Task.Delay(1)loop that callsInvokeAsync(StateHasChanged). Disposing it 100 times leaves 23 to 33 components undisposed per run on 2.9.0, and 25 on 2.11.3.Against PR #1940 (commit
064dc69f, built locally for net10.0), both tests pass. The deterministic test passed in every run, and the probabilistic test left 0 of 100 components undisposed in each of 4 runs.Happy to run the same tests against later revisions of the fix.
FLK2: the Sessions page gives its lease back on time, and the test co…