Skip to content

DisposeComponentsAsync() can dispose nothing while a timer is rendering #1939

Closed

Description

@xfunc-tim-schneider

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:

  1. Run dotnet new xunit -o BunitRepro -f net10.0, then cd BunitRepro.
  2. In BunitRepro.csproj, change Sdk="Microsoft.NET.Sdk" to Sdk="Microsoft.NET.Sdk.Razor", and delete UnitTest1.cs.
  3. Run dotnet add package bunit --version 2.11.3.
  4. Add FastCounter.razor and DisposeComponentsTimerTest.cs from below.
  5. 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 main at 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

  1. linkdotnet commented on Oct 16, 2026

    @linkdotnet
    Collaborator

    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.

  2. added 3 commits that reference this issue on Oct 16, 2026
    e05a98d
    d8e4d84
    064dc69
  3. added a commit that references this issue on Oct 16, 2026
    945163b
  4. hediasjarno commented on Oct 16, 2026

    @hediasjarno

    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 DisposeAsync awaited 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:

    [Fact]
    public async Task Dispatcher_held_at_dispose_leaves_component_undisposed()
    {
        await using var ctx = new BunitContext();
        var cut = ctx.Render<Probe>();
        var component = cut.Instance;
    
        var released = new ManualResetEventSlim();
        var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
        var holding = Task.Run(() => cut.InvokeAsync(() =>
        {
            entered.SetResult();
            released.Wait();
        }));
        await entered.Task;
    
        var disposing = ctx.DisposeComponentsAsync();
        released.Set();
        await holding;
        await disposing;
    
        Assert.True(component.IsDisposed);
    }

    Probe is a plain ComponentBase that sets IsDisposed in Dispose():

    public sealed class Probe : ComponentBase, IDisposable
    {
        public bool IsDisposed { get; private set; }
        protected override void BuildRenderTree(RenderTreeBuilder builder) => builder.AddContent(0, "probe");
        public void Dispose() => IsDisposed = true;
    }

    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 calls InvokeAsync(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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions