|
| 1 | +using System.Runtime.CompilerServices; |
1 | 2 | using AngleSharp.Dom; |
2 | 3 |
|
3 | 4 | namespace Bunit.Extensions.WaitForHelpers; |
@@ -240,6 +241,55 @@ await cut.WaitForAssertionAsync(async () => |
240 | 241 | expected.Message.ShouldStartWith(WaitForAssertionHelper<Simple1>.TimeoutMessage); |
241 | 242 | } |
242 | 243 |
|
| 244 | + [Fact(DisplayName = "WaitForAssertion does not keep the renderer alive after the first check passes")] |
| 245 | + public async Task Test500() |
| 246 | + { |
| 247 | + // The first call is slow while it is being JIT compiled, which is enough for the wait task |
| 248 | + // to complete before the helper decides whether to start its timer, so it is not measured. |
| 249 | + RenderAndWaitForAssertionThatPassesAtOnce(); |
| 250 | + |
| 251 | + var renderers = Enumerable.Range(0, 10) |
| 252 | + .Select(_ => RenderAndWaitForAssertionThatPassesAtOnce()) |
| 253 | + .ToList(); |
| 254 | + |
| 255 | + // Disposing a context queues work that still references the renderer: cancelling its |
| 256 | + // unhandled exception task wakes the helper's failure continuation on the thread pool, and |
| 257 | + // the renderer's own base disposal goes through the dispatcher. On a busy test host that |
| 258 | + // can still be queued when the first collection runs, so the collection is retried for a |
| 259 | + // short while. A timer left running roots the renderer for ten minutes, so no number of |
| 260 | + // retries makes the leaking case pass. |
| 261 | + var alive = renderers.Count; |
| 262 | + for (var attempt = 0; attempt < 20 && alive > 0; attempt++) |
| 263 | + { |
| 264 | + if (attempt > 0) |
| 265 | + await Task.Delay(50, Xunit.TestContext.Current.CancellationToken); |
| 266 | + |
| 267 | +#pragma warning disable S1215 // A full collection is what proves the renderers are no longer rooted. |
| 268 | + GC.Collect(); |
| 269 | + GC.WaitForPendingFinalizers(); |
| 270 | + GC.Collect(); |
| 271 | +#pragma warning restore S1215 |
| 272 | + |
| 273 | + alive = renderers.Count(r => r.IsAlive); |
| 274 | + } |
| 275 | + |
| 276 | + alive.ShouldBe(0, "a wait whose first check passed should leave no timer running"); |
| 277 | + } |
| 278 | + |
| 279 | + // Kept out of the test method so that nothing on its stack frame keeps the context alive. |
| 280 | + [MethodImpl(MethodImplOptions.NoInlining)] |
| 281 | + private static WeakReference RenderAndWaitForAssertionThatPassesAtOnce() |
| 282 | + { |
| 283 | + using var context = new BunitContext(); |
| 284 | + var cut = context.Render<Simple1>(); |
| 285 | + |
| 286 | + // A timeout far longer than the test: a timer left running roots the helper, |
| 287 | + // and through it the renderer, until it fires. |
| 288 | + cut.WaitForAssertion(() => { }, TimeSpan.FromMinutes(10)); |
| 289 | + |
| 290 | + return new WeakReference(context.Renderer); |
| 291 | + } |
| 292 | + |
243 | 293 | private sealed class ThrowsAfterAsyncOperation : ComponentBase |
244 | 294 | { |
245 | 295 | protected override async Task OnInitializedAsync() |
|
0 commit comments