From 58740058ae18a5c7d63a9117d1a0b2bab19ffc96 Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Tue, 8 Sep 2026 08:25:06 +0000 Subject: [PATCH 01/12] Set version to '2.11-preview' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index fca6d42a3..23c3aaf0f 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.10-preview", + "version": "2.11-preview", "assemblyVersion": { "precision": "revision" }, From 0af9cf53b5e9ad200fa216da7cc4d76e1cc38aa6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:14:30 +0000 Subject: [PATCH 02/12] Bump AngleSharp from 1.8.0 to 1.8.1 --- updated-dependencies: - dependency-name: AngleSharp dependency-version: 1.8.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 56664c00a..9502ead3e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + From f08c8d52bc56c751464d6ade1c1d5c376da71236 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:33:37 +0000 Subject: [PATCH 03/12] Bump AngleSharp.Css from 1.1.0 to 1.1.1 --- updated-dependencies: - dependency-name: AngleSharp.Css dependency-version: 1.1.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 9502ead3e..dfdebd433 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,7 @@ - + From 0713f17670439b784b3619f4de485a20b8d13da0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:14:58 +0000 Subject: [PATCH 04/12] Bump AngleSharp.Css from 1.1.1 to 1.1.2 --- updated-dependencies: - dependency-name: AngleSharp.Css dependency-version: 1.1.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index dfdebd433..259df022e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,7 +19,7 @@ - + From 82b421b3f0258bdb4593d6a33017c7f690b24e96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:23:49 +0000 Subject: [PATCH 05/12] Bump Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.0 --- updated-dependencies: - dependency-name: Microsoft.NET.Test.Sdk dependency-version: 18.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 259df022e..6077a751a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -91,7 +91,7 @@ - + From bf6cfa38a6b66bab201ba26c04e0354018c3ec3e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:15:00 +0000 Subject: [PATCH 06/12] Bump dotnet-dump from 10.0.731102 to 10.0.745401 --- updated-dependencies: - dependency-name: dotnet-dump dependency-version: 10.0.745401 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 372393c31..bfb6e7112 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "dotnet-dump": { - "version": "10.0.731102", + "version": "10.0.745401", "commands": [ "dotnet-dump" ], From b4c8549fba58d2a983d0bb0ca59d4b23a586f6e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:14:24 +0000 Subject: [PATCH 07/12] Bump Meziantou.Polyfill from 1.0.164 to 1.0.165 --- updated-dependencies: - dependency-name: Meziantou.Polyfill dependency-version: 1.0.165 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6077a751a..25430dbb8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + From 9e03e44b7d87aea1862dcac34b5f8c23dd560be8 Mon Sep 17 00:00:00 2001 From: Vincent Baaij Date: Sun, 13 Sep 2026 13:47:25 +0200 Subject: [PATCH 08/12] fix: Alter InvokeOnSpacerBeforeVisible for NET 11.0 so it uses 4 parameters (#1919) --- Directory.Packages.props | 24 +++++----- .../VirtualizeJSRuntimeInvocationHandler.cs | 14 +++++- tests/Directory.Build.props | 4 +- .../SimpleAuthViewWithClaims.razor | 44 +++++++++---------- .../SimpleWithHttpClient.razor | 3 +- .../SimpleWithJSRuntimeDep.razor | 6 +-- .../bunit.testassets/bunit.testassets.csproj | 2 +- tests/bunit.tests/bunit.tests.csproj | 4 +- 8 files changed, 55 insertions(+), 46 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 25430dbb8..04c706758 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -72,18 +72,18 @@ - - - - - - - - - - - - + + + + + + + + + + + + diff --git a/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs b/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs index 2fb2abd73..a771cd259 100644 --- a/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs +++ b/src/bunit/JSInterop/InvocationHandlers/Implementation/VirtualizeJSRuntimeInvocationHandler.cs @@ -1,6 +1,6 @@ -using Microsoft.AspNetCore.Components.Web.Virtualization; using System.Diagnostics; using System.Reflection; +using Microsoft.AspNetCore.Components.Web.Virtualization; namespace Bunit.JSInterop.InvocationHandlers.Implementation; @@ -35,10 +35,15 @@ internal VirtualizeJSRuntimeInvocationHandler() /// protected internal override Task HandleAsync(JSRuntimeInvocation invocation) { - if (!invocation.Identifier.Equals(JsFunctionsPrefix + "dispose", StringComparison.Ordinal)) + if (!invocation.Identifier.Equals(JsFunctionsPrefix + "dispose", StringComparison.Ordinal) && + !invocation.Identifier.Equals(JsFunctionsPrefix + "refreshObservers", StringComparison.Ordinal)) { Debug.Assert(invocation.Identifier.Equals(JsFunctionsPrefix + "init", StringComparison.Ordinal)); +#if NET11_0_OR_GREATER + Debug.Assert(invocation.Arguments.Count == 4); +#else Debug.Assert(invocation.Arguments.Count == 3); +#endif Debug.Assert(invocation.Arguments[0] is not null); InvokeOnSpacerBeforeVisible(invocation.Arguments[0]!); @@ -58,7 +63,12 @@ private static void InvokeOnSpacerBeforeVisible(object dotNetObjectReference) 0f, /* spacerSize */ 0f, /* spacerSeparation */ 1_000_000_000f, /* containerSize - very large number to ensure all items are loaded at once */ +#if NET11_0_OR_GREATER + 0, /* UserScroll */ +#endif + }; + onSpacerBeforeVisibleMethodInfo.Invoke(virtualizeJsInterop, parameters); } } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index cf491ca78..0b5093695 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -14,9 +14,9 @@ true Exe - + - diff --git a/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor b/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor index d889342d0..3fd2357d4 100644 --- a/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor +++ b/tests/bunit.testassets/SampleComponents/SimpleAuthViewWithClaims.razor @@ -3,30 +3,30 @@ @inject AuthenticationStateProvider AuthenticationStateProvider -
Authorized!
-
Name: @userName
- @if (hasUserEmail) - { -
Email: @userEmail
- } - @if (hasUserId) - { -
Id: @userId
- } +
Authorized!
+
Name: @userName
+ @if (hasUserEmail) + { +
Email: @userEmail
+ } + @if (hasUserId) + { +
Id: @userId
+ }
@code { - string userName = ""; - string? userEmail = ""; - string? userId = ""; - bool hasUserEmail => userEmail != null; - bool hasUserId => userId != null; + string userName = ""; + string? userEmail = ""; + string? userId = ""; + bool hasUserEmail => userEmail != null; + bool hasUserId => userId != null; - protected override async Task OnParametersSetAsync() - { - var state = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - userName = state?.User?.Identity?.Name ?? string.Empty; - userEmail = state?.User?.FindFirst(ClaimTypes.Email)?.Value; - userId = state?.User?.FindFirst(ClaimTypes.Sid)?.Value; - } + protected override async Task OnParametersSetAsync() + { + var state = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + userName = state?.User?.Identity?.Name ?? string.Empty; + userEmail = state?.User?.FindFirst(ClaimTypes.Email)?.Value; + userId = state?.User?.FindFirst(ClaimTypes.Sid)?.Value; + } } diff --git a/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor b/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor index b3ec30bf0..97468e805 100644 --- a/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor +++ b/tests/bunit.testassets/SampleComponents/SimpleWithHttpClient.razor @@ -1,4 +1,4 @@ -@inject HttpClient HttpClient +@inject HttpClient HttpClient

SimpleWithHttpClient

@@ -7,6 +7,5 @@ protected override async Task OnInitializedAsync() { await HttpClient.GetAsync("/api/weather"); - StateHasChanged(); } } diff --git a/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor b/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor index 4c8f6d1d0..c6f15920c 100644 --- a/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor +++ b/tests/bunit.testassets/SampleComponents/SimpleWithJSRuntimeDep.razor @@ -1,6 +1,6 @@ -@inject IJSRuntime jsRuntime +@inject IJSRuntime jsRuntime

@name

-@code{ +@code { string name = string.Empty; protected override async Task OnAfterRenderAsync(bool firstRender) @@ -11,4 +11,4 @@ StateHasChanged(); } } -} \ No newline at end of file +} diff --git a/tests/bunit.testassets/bunit.testassets.csproj b/tests/bunit.testassets/bunit.testassets.csproj index 305d09964..b8d28daec 100644 --- a/tests/bunit.testassets/bunit.testassets.csproj +++ b/tests/bunit.testassets/bunit.testassets.csproj @@ -1,4 +1,4 @@ - + net8.0;net9.0;net10.0;net11.0 diff --git a/tests/bunit.tests/bunit.tests.csproj b/tests/bunit.tests/bunit.tests.csproj index 638453b1a..93b44ce54 100644 --- a/tests/bunit.tests/bunit.tests.csproj +++ b/tests/bunit.tests/bunit.tests.csproj @@ -7,12 +7,12 @@ - + - + \ No newline at end of file From f3b5e130db090f81764b8a5c96bf58dca4baa54f Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 13 Sep 2026 13:53:05 +0200 Subject: [PATCH 09/12] docs: Changelog entry --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5760e951..8b047d140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Fixed + +- `InvokeOnSpacerBeforeVisible` now uses 4 parameters on .NET 11.0. Reported by [@vnbaaij](https://github.com/vnbaaij) in #1915. Fixed by [@vnbaaij](https://github.com/vnbaaij) in #1919. + ## [2.10.3] - 2026-09-08 ### Fixed From 54397277cc7bb2af981d104435425cefe349bb1e Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Sun, 13 Sep 2026 13:57:37 +0200 Subject: [PATCH 10/12] fix: BL00x issues with rc1 --- .editorconfig | 1 + .globalconfig | 6 ++++++ bunit.slnx | 1 + 3 files changed, 8 insertions(+) create mode 100644 .globalconfig diff --git a/.editorconfig b/.editorconfig index 1fbb86aae..6929f0262 100644 --- a/.editorconfig +++ b/.editorconfig @@ -467,6 +467,7 @@ dotnet_diagnostic.BL0005.severity = none dotnet_diagnostic.BL0006.severity = none dotnet_diagnostic.BL0007.severity = none dotnet_diagnostic.BL0010.severity = none +dotnet_diagnostic.BL0012.severity = none ########################################## # Custom Test Code Analyzers Rules diff --git a/.globalconfig b/.globalconfig new file mode 100644 index 000000000..c872976b8 --- /dev/null +++ b/.globalconfig @@ -0,0 +1,6 @@ +# Razor compiler diagnostics reported on .razor files are not matched by .editorconfig +# path sections, so their severity can only be configured globally. +is_global = true + +dotnet_diagnostic.BL0013.severity = none +dotnet_diagnostic.BL0016.severity = none diff --git a/bunit.slnx b/bunit.slnx index 8c453b444..3a300cb00 100644 --- a/bunit.slnx +++ b/bunit.slnx @@ -3,6 +3,7 @@ + From c2ff60890cd7b462765caf1ac0c5427256b51fb8 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Fri, 11 Sep 2026 22:01:38 +0200 Subject: [PATCH 11/12] fix: OnTimeoutElapsed crashes / refactor to use upstream version (fixes #1920) --- CHANGELOG.md | 1 + ...JSRuntimeInvocationHandlerBase{TResult}.cs | 129 +++++++++++------- .../JSInterop/BunitJSInteropTimeoutTest.cs | 96 ++++++++++++- 3 files changed, 175 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b047d140..525af7b12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to **bUnit** will be documented in this file. The project ad ### Fixed - `InvokeOnSpacerBeforeVisible` now uses 4 parameters on .NET 11.0. Reported by [@vnbaaij](https://github.com/vnbaaij) in #1915. Fixed by [@vnbaaij](https://github.com/vnbaaij) in #1919. +- A JSInterop timeout elapsing while a result was set could crash the test host with `InvalidOperationException: Nullable object must have a value`. Reported by [@calebcwells](https://github.com/calebcwells) in [#1920](https://github.com/bUnit-dev/bUnit/issues/1920). Fixed by [@linkdotnet](https://github.com/linkdotnet). ## [2.10.3] - 2026-09-08 diff --git a/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs b/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs index 5aa093eb2..53026ff4d 100644 --- a/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs +++ b/src/bunit/JSInterop/InvocationHandlers/JSRuntimeInvocationHandlerBase{TResult}.cs @@ -1,14 +1,20 @@ +using System.Collections.Concurrent; + namespace Bunit; +// Invocation tracking mirrors ASP.NET Core's JSRuntime: no per-invocation state lives in instance +// fields. Each call gets its own TaskCompletionSource in a ConcurrentDictionary keyed by an +// Interlocked id, and the timeout closes over that entry alone, so an elapsing timeout can never +// race a concurrently set result. See https://github.com/dotnet/aspnetcore/blob/main/src/JSInterop/Microsoft.JSInterop/src/JSRuntime.cs /// /// Represents an invocation handler for instances. /// public abstract class JSRuntimeInvocationHandlerBase : IDisposable { private readonly InvocationMatcher invocationMatcher; - private TaskCompletionSource completionSource; - private Timer? timeoutTimer; - private JSRuntimeInvocation? currentInvocation; + private readonly ConcurrentDictionary pendingInvocations = new(); + private long nextInvocationId; + private Task? outcome; private bool disposed; /// @@ -34,7 +40,6 @@ public abstract class JSRuntimeInvocationHandlerBase : IDisposable protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatchAllHandler) { invocationMatcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); - completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); IsCatchAllHandler = isCatchAllHandler; } @@ -42,13 +47,7 @@ protected JSRuntimeInvocationHandlerBase(InvocationMatcher matcher, bool isCatch /// Marks the that invocations will receive as canceled. /// protected void SetCanceledBase() - { - ClearTimeoutTimer(); - if (completionSource.Task.IsCompleted) - completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - completionSource.SetCanceled(); - } + => CompleteAll(Task.FromCanceled(new CancellationToken(canceled: true))); /// /// Sets the exception that invocations will receive. @@ -56,26 +55,14 @@ protected void SetCanceledBase() /// The type of exception to pass to the callers. protected void SetExceptionBase(TException exception) where TException : Exception - { - ClearTimeoutTimer(); - if (completionSource.Task.IsCompleted) - completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - completionSource.SetException(exception); - } + => CompleteAll(Task.FromException(exception)); /// /// Sets the result that invocations will receive. /// /// The type of result to pass to the callers. protected void SetResultBase(TResult result) - { - ClearTimeoutTimer(); - if (completionSource.Task.IsCompleted) - completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - - completionSource.SetResult(result); - } + => CompleteAll(Task.FromResult(result)); /// /// Call this to have the this handler handle the . @@ -89,18 +76,29 @@ protected internal virtual Task HandleAsync(JSRuntimeInvocation invocat { Invocations.RegisterInvocation(invocation); - var task = completionSource.Task; - if (task is { IsCanceled: false, IsFaulted: false, IsCompletedSuccessfully: false }) + if (Volatile.Read(ref outcome) is { } configured) + return configured; + + var timeout = BunitContext.DefaultWaitTimeout; + if (timeout <= TimeSpan.Zero) { - if (BunitContext.DefaultWaitTimeout <= TimeSpan.Zero) - { - throw new JSRuntimeInvocationNotSetException(invocation); - } + throw new JSRuntimeInvocationNotSetException(invocation); + } + + var id = Interlocked.Increment(ref nextInvocationId); + var pending = new PendingInvocation(id, invocation); + pendingInvocations[id] = pending; - StartTimeoutTimer(invocation); + if (Volatile.Read(ref outcome) is { } raced && pendingInvocations.TryRemove(id, out _)) + { + Transfer(raced, pending.CompletionSource); + } + else + { + pending.StartTimeout(OnTimeoutElapsed, timeout); } - return task; + return pending.CompletionSource.Task; } /// @@ -122,34 +120,71 @@ protected virtual void Dispose(bool disposing) { if (!disposed && disposing) { - ClearTimeoutTimer(); + foreach (var id in pendingInvocations.Keys) + { + if (pendingInvocations.TryRemove(id, out var pending)) + pending.Dispose(); + } + disposed = true; } } - private void StartTimeoutTimer(JSRuntimeInvocation invocation) + private void CompleteAll(Task next) { - ClearTimeoutTimer(); + Volatile.Write(ref outcome, next); - currentInvocation = invocation; - timeoutTimer = new Timer(OnTimeoutElapsed, null, BunitContext.DefaultWaitTimeout, Timeout.InfiniteTimeSpan); + foreach (var id in pendingInvocations.Keys) + { + if (pendingInvocations.TryRemove(id, out var pending)) + { + pending.Dispose(); + Transfer(next, pending.CompletionSource); + } + } } - private void ClearTimeoutTimer() + private void OnTimeoutElapsed(object? state) { - timeoutTimer?.Dispose(); - timeoutTimer = null; - currentInvocation = null; + if (state is not PendingInvocation pending || !pendingInvocations.TryRemove(pending.Id, out _)) + return; + + pending.Dispose(); + pending.CompletionSource.TrySetException(new JSRuntimeInvocationNotSetException(pending.Invocation)); } - private void OnTimeoutElapsed(object? state) + private static void Transfer(Task from, TaskCompletionSource to) { - if (!completionSource.Task.IsCompleted && currentInvocation.HasValue) + if (from.IsCanceled) + to.TrySetCanceled(); + else if (from.Exception is { } exception) + to.TrySetException(exception.InnerExceptions); + else + to.TrySetResult(from.Result); + } + + private sealed class PendingInvocation : IDisposable + { + private Timer? timeoutTimer; + + public long Id { get; } + + public JSRuntimeInvocation Invocation { get; } + + public TaskCompletionSource CompletionSource { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public PendingInvocation(long id, JSRuntimeInvocation invocation) + { + Id = id; + Invocation = invocation; + } + + public void StartTimeout(TimerCallback callback, TimeSpan timeout) { - var exception = new JSRuntimeInvocationNotSetException(currentInvocation.Value); - completionSource.TrySetException(exception); + timeoutTimer = new Timer(callback, this, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + timeoutTimer.Change(timeout, Timeout.InfiniteTimeSpan); } - ClearTimeoutTimer(); + public void Dispose() => timeoutTimer?.Dispose(); } } diff --git a/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs b/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs index 811bd351c..eb5cc4a28 100644 --- a/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs +++ b/tests/bunit.tests/JSInterop/BunitJSInteropTimeoutTest.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; + namespace Bunit.JSInterop; [CollectionDefinition(nameof(DefaultWaitTimeoutTestGroup), DisableParallelization = true)] @@ -12,12 +14,9 @@ public class BunitJSInteropTimeoutTest public async Task Test309() { const string identifier = "testFunction"; - var originalTimeout = BunitContext.DefaultWaitTimeout; - try + await WithDefaultWaitTimeout(TimeSpan.FromMilliseconds(100), async () => { - BunitContext.DefaultWaitTimeout = TimeSpan.FromMilliseconds(100); - var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict }; sut.Setup(identifier); @@ -25,6 +24,95 @@ public async Task Test309() var exception = await Should.ThrowAsync(invocationTask.AsTask()); exception.Invocation.Identifier.ShouldBe(identifier); + }); + } + + [Fact(DisplayName = "Each pending invocation times out with its own invocation")] + public async Task Test310() + { + await WithDefaultWaitTimeout(TimeSpan.FromMilliseconds(100), async () => + { + var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict }; + sut.Setup(_ => true); + + var first = sut.JSRuntime.InvokeAsync("first").AsTask(); + var second = sut.JSRuntime.InvokeAsync("second").AsTask(); + + (await Should.ThrowAsync(first)) + .Invocation.Identifier.ShouldBe("first"); + (await Should.ThrowAsync(second)) + .Invocation.Identifier.ShouldBe("second"); + }); + } + + [Fact(DisplayName = "A timed out invocation does not affect later invocations")] + public async Task Test311() + { + const string identifier = "testFunction"; + + await WithDefaultWaitTimeout(TimeSpan.FromMilliseconds(100), async () => + { + var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict }; + var handler = sut.Setup(identifier); + + await Should.ThrowAsync( + sut.JSRuntime.InvokeAsync(identifier).AsTask()); + + handler.SetResult(42); + + (await sut.JSRuntime.InvokeAsync(identifier)).ShouldBe(42); + }); + } + + [Fact(DisplayName = "Setting a result while the timeout elapses does not crash the test host")] + public async Task Test312() + { + const string identifier = "testFunction"; + var timeout = TimeSpan.FromMilliseconds(2); + + await WithDefaultWaitTimeout(timeout, async () => + { + var workers = Enumerable + .Range(0, Math.Max(4, Environment.ProcessorCount)) + .Select(_ => Task.Run(() => RaceResultAgainstTimeout(identifier, timeout, iterations: 250))); + + await Task.WhenAll(workers); + }); + } + + private static async Task RaceResultAgainstTimeout(string identifier, TimeSpan timeout, int iterations) + { + for (var i = 0; i < iterations; i++) + { + var sut = new BunitJSInterop { Mode = JSRuntimeMode.Strict }; + var handler = sut.Setup(identifier); + + var invocationTask = sut.JSRuntime.InvokeAsync(identifier).AsTask(); + + // Spin until the timer is due so that setting the result races the elapsing timeout. + var spin = Stopwatch.StartNew(); + while (spin.Elapsed < timeout) + Thread.SpinWait(1); + + handler.SetResult(i); + + // Either the result or the timeout may win the race, but the invocation must + // always complete and never surface anything but the timeout exception. + var completed = await Task.WhenAny(invocationTask, Task.Delay(TimeSpan.FromSeconds(10))); + completed.ShouldBe(invocationTask); + + if (invocationTask.Exception is { } exception) + exception.InnerException.ShouldBeOfType(); + } + } + + private static async Task WithDefaultWaitTimeout(TimeSpan timeout, Func test) + { + var originalTimeout = BunitContext.DefaultWaitTimeout; + BunitContext.DefaultWaitTimeout = timeout; + try + { + await test(); } finally { From 38fd4dce54a761e7a65ae2e3087ff36c257a66d1 Mon Sep 17 00:00:00 2001 From: bUnit bot Date: Sun, 13 Sep 2026 12:09:42 +0000 Subject: [PATCH 12/12] Set version to '2.11' --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 23c3aaf0f..3f50706f4 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "2.11-preview", + "version": "2.11", "assemblyVersion": { "precision": "revision" },