From 026c15d33e0ab6e2ada605e290890d81da2705be Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Tue, 19 May 2026 02:30:51 -0300 Subject: [PATCH 1/4] Prevent deadlock on DiagnosticsCollector --- .../Diagnostics/DiagnosticsChannel.cs | 3 + .../Diagnostics/DiagnosticsCollector.cs | 34 ++++++---- .../Diagnostics/IDiagnosticsCollector.cs | 10 +++ .../DiagnosticsCollectorDisposeTests.cs | 66 +++++++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs index d26bb57ec0..24f28aa798 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs @@ -10,6 +10,7 @@ public sealed class DiagnosticsChannel : IDisposable { private readonly Channel _channel; private readonly CancellationTokenSource _ctxSource; + public ChannelReader Reader => _channel.Reader; public Cancel CancellationToken => _ctxSource.Token; @@ -33,6 +34,8 @@ public void TryComplete(Exception? exception = null) public ValueTask WaitToWrite(Cancel ctx) => _channel.Writer.WaitToWriteAsync(ctx); + // Unbounded channel: TryWrite only fails if the writer is completed, which + // means a producer raced past StopAsync/DisposeAsync. public void Write(Diagnostic diagnostic) { var written = _channel.Writer.TryWrite(diagnostic); diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs index ec3fc38f32..d40d99db2d 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs @@ -3,7 +3,6 @@ // See the LICENSE file in the project root for more information using System.Collections.Concurrent; -using System.Diagnostics; using System.IO.Abstractions; using Microsoft.Extensions.Hosting; @@ -31,6 +30,8 @@ public class DiagnosticsCollector(IReadOnlyCollection output public bool NoHints { get; set; } + public bool IsStarted => _started is not null; + public virtual DiagnosticsCollector StartAsync(Cancel ctx) { _ = ((IHostedService)this).StartAsync(ctx); @@ -60,18 +61,18 @@ Task IHostedService.StartAsync(Cancel cancellationToken) Drain(); }, cancellationToken); return _started; + } - void Drain() + private void Drain() + { + while (Channel.Reader.TryRead(out var item)) { - while (Channel.Reader.TryRead(out var item)) - { - if (item.Severity == Severity.Hint && NoHints) - continue; - HandleItem(item); - _ = OffendingFiles.Add(item.File); - foreach (var output in outputs) - output.Write(item); - } + if (item.Severity == Severity.Hint && NoHints) + continue; + HandleItem(item); + _ = OffendingFiles.Add(item.File); + foreach (var output in outputs) + output.Write(item); } } @@ -90,8 +91,15 @@ protected virtual void HandleItem(Diagnostic diagnostic) { } public virtual async Task StopAsync(Cancel cancellationToken) { Channel.TryComplete(); - if (_started is not null) - await _started; + // If StartAsync was never called there is no background reader; drain + // synchronously so emitted diagnostics still reach HandleItem/outputs + // instead of deadlocking on Channel.Reader.Completion. + if (_started is null) + { + Drain(); + return; + } + await _started; await Channel.Reader.Completion; } diff --git a/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs index 6e804ad76e..374712a419 100644 --- a/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs @@ -21,6 +21,9 @@ public interface IDiagnosticsCollector : IAsyncDisposable, IHostedService HashSet OffendingFiles { get; } ConcurrentDictionary InUseSubstitutionKeys { get; } + /// True once StartAsync has been called and a background reader is draining the channel. + bool IsStarted => true; + void Emit(Severity severity, string file, string message); void EmitError(string file, string message, Exception? e = null); void EmitError(string file, string message, string specificErrorMessage); @@ -49,6 +52,13 @@ public interface IDiagnosticsCollector : IAsyncDisposable, IHostedService async Task WaitForDrain() { + if (!IsStarted) + { + throw new InvalidOperationException( + "WaitForDrain called on a collector that was never started; no reader is draining the channel. " + + "Call StartAsync first or dispose the collector to drain synchronously."); + } + var start = DateTime.UtcNow; while (Channel.Reader.TryPeek(out _)) { diff --git a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs new file mode 100644 index 0000000000..a8a4f99a6c --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs @@ -0,0 +1,66 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using AwesomeAssertions; +using Elastic.Documentation.Diagnostics; + +namespace Elastic.Changelog.Tests.Changelogs; + +public class DiagnosticsCollectorDisposeTests +{ + private sealed class RecordingOutput : IDiagnosticsOutput + { + public List Items { get; } = []; + public void Write(Diagnostic diagnostic) => Items.Add(diagnostic); + } + + // Regression: the changelog-scrubber lambda used a DiagnosticsCollector without calling + // StartAsync. Emitting a diagnostic and then disposing deadlocked on + // Channel.Reader.Completion because nothing was draining the channel, + // causing the lambda to hit its 180s timeout. + [Fact] + public async Task DisposeAsync_WithoutStartAsync_DoesNotHang() + { + var output = new RecordingOutput(); + var collector = new DiagnosticsCollector([output]); + collector.EmitWarning("file.yaml", "test warning that nobody is reading"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var disposeTask = collector.DisposeAsync().AsTask(); + var completed = await Task.WhenAny(disposeTask, Task.Delay(Timeout.Infinite, cts.Token)); + + completed.Should().BeSameAs(disposeTask, "DisposeAsync must not deadlock when StartAsync was never called"); + await disposeTask; + collector.Warnings.Should().Be(1); + collector.OffendingFiles.Should().Contain("file.yaml", "Dispose must drain queued diagnostics so outputs and OffendingFiles still observe them"); + output.Items.Should().HaveCount(1); + } + + [Fact] + public async Task StopAsync_WithoutStartAsync_DoesNotHang() + { + var output = new RecordingOutput(); + var collector = new DiagnosticsCollector([output]); + collector.EmitError("file.yaml", "test error that nobody is reading"); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var stopTask = collector.StopAsync(CancellationToken.None); + var completed = await Task.WhenAny(stopTask, Task.Delay(Timeout.Infinite, cts.Token)); + + completed.Should().BeSameAs(stopTask, "StopAsync must not deadlock when StartAsync was never called"); + await stopTask; + collector.Errors.Should().Be(1); + output.Items.Should().HaveCount(1, "Stop must drain queued diagnostics to outputs even without a background reader"); + } + + [Fact] + public async Task WaitForDrain_WithoutStartAsync_ThrowsImmediately() + { + var collector = new DiagnosticsCollector([]); + collector.EmitWarning(string.Empty, "queued"); + + Func act = () => ((IDiagnosticsCollector)collector).WaitForDrain(); + _ = await act.Should().ThrowAsync(); + } +} From 25979cd8d438c0033e25cc4ebc69a5ee0bfbc522 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 20 May 2026 10:28:49 -0300 Subject: [PATCH 2/4] Apply review feedback: gate writes, track reader start, simplify dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gate Channel.Write on a _readerStarted flag set inside the reader delegate, so writes are dropped when no reader will drain them (per Mpdreamz). - _readerStarted, not _started != null, drives IsStarted; Task.Run with an already-canceled token leaves _started non-null without ever running the delegate (per CodeRabbit). - StopAsync no longer needs a sync-drain branch — gating keeps the channel empty when no reader started. Keep a defensive Drain after awaiting the reader Task to mop up if it exited early via cancellation. - Drop the default IsStarted => true on IDiagnosticsCollector and require implementers to declare it (per reakaleek + Mpdreamz). - Add a regression test for instantiate-and-dispose with no emissions (per Mpdreamz). Co-authored-by: Cursor --- .../Diagnostics/DiagnosticsCollector.cs | 37 ++++++++++---- .../Diagnostics/IDiagnosticsCollector.cs | 4 +- .../DiagnosticsCollectorDisposeTests.cs | 49 +++++++++++++------ 3 files changed, 62 insertions(+), 28 deletions(-) diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs index d40d99db2d..863fd0329b 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs @@ -21,6 +21,12 @@ public class DiagnosticsCollector(IReadOnlyCollection output public int Hints => _hints; private Task? _started; + // True once the background reader delegate has actually begun executing. + // _started becoming non-null is not enough: Task.Run short-circuits to a + // canceled Task when given an already-canceled token, never running the + // delegate. We need to know the reader will actually drain the channel + // before we let writes accumulate in it. + private volatile bool _readerStarted; public HashSet OffendingFiles { get; } = []; @@ -30,7 +36,7 @@ public class DiagnosticsCollector(IReadOnlyCollection output public bool NoHints { get; set; } - public bool IsStarted => _started is not null; + public bool IsStarted => _readerStarted; public virtual DiagnosticsCollector StartAsync(Cancel ctx) { @@ -44,6 +50,7 @@ Task IHostedService.StartAsync(Cancel cancellationToken) return _started; _started = Task.Run(async () => { + _readerStarted = true; _ = await Channel.WaitToWrite(cancellationToken); while (!Channel.CancellationToken.IsCancellationRequested) { @@ -91,16 +98,22 @@ protected virtual void HandleItem(Diagnostic diagnostic) { } public virtual async Task StopAsync(Cancel cancellationToken) { Channel.TryComplete(); - // If StartAsync was never called there is no background reader; drain - // synchronously so emitted diagnostics still reach HandleItem/outputs - // instead of deadlocking on Channel.Reader.Completion. - if (_started is null) - { - Drain(); + // No reader was ever scheduled, so Write() gated everything out and the + // channel is empty — nothing to await, nothing to drain. + if (!_readerStarted) return; + + try + { + await _started!; + } + catch (OperationCanceledException) + { + // Reader was canceled before its final Drain(); mop up below. } - await _started; - await Channel.Reader.Completion; + // Defensive: if the reader exited early via cancellation, items may + // still be queued. Drain them synchronously so they're not lost. + Drain(); } public void EmitCrossLink(string link) => CrossLinks.Add(link); @@ -108,7 +121,11 @@ public virtual async Task StopAsync(Cancel cancellationToken) public virtual void Write(Diagnostic diagnostic) { IncrementSeverityCount(diagnostic); - Channel.Write(diagnostic); + // Severity counters are always accurate; the channel only matters if a + // reader will actually drain it. Skip the channel write otherwise so + // items don't accumulate and StopAsync has nothing to wait for. + if (_readerStarted) + Channel.Write(diagnostic); } public void Emit(Severity severity, string file, string message) => diff --git a/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs index 374712a419..7021eddb46 100644 --- a/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/IDiagnosticsCollector.cs @@ -21,8 +21,8 @@ public interface IDiagnosticsCollector : IAsyncDisposable, IHostedService HashSet OffendingFiles { get; } ConcurrentDictionary InUseSubstitutionKeys { get; } - /// True once StartAsync has been called and a background reader is draining the channel. - bool IsStarted => true; + /// True once the background reader is actively draining the channel. + bool IsStarted { get; } void Emit(Severity severity, string file, string message); void EmitError(string file, string message, Exception? e = null); diff --git a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs index a8a4f99a6c..500b06bde7 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs @@ -15,43 +15,60 @@ private sealed class RecordingOutput : IDiagnosticsOutput public void Write(Diagnostic diagnostic) => Items.Add(diagnostic); } + private static async Task ShouldComplete(Task task, TimeSpan timeout, string because) + { + using var cts = new CancellationTokenSource(timeout); + var completed = await Task.WhenAny(task, Task.Delay(Timeout.Infinite, cts.Token)); + completed.Should().BeSameAs(task, because); + await task; + } + // Regression: the changelog-scrubber lambda used a DiagnosticsCollector without calling // StartAsync. Emitting a diagnostic and then disposing deadlocked on // Channel.Reader.Completion because nothing was draining the channel, // causing the lambda to hit its 180s timeout. [Fact] - public async Task DisposeAsync_WithoutStartAsync_DoesNotHang() + public async Task DisposeAsync_WithoutStartAsyncAfterEmit_DoesNotHang() { var output = new RecordingOutput(); var collector = new DiagnosticsCollector([output]); collector.EmitWarning("file.yaml", "test warning that nobody is reading"); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var disposeTask = collector.DisposeAsync().AsTask(); - var completed = await Task.WhenAny(disposeTask, Task.Delay(Timeout.Infinite, cts.Token)); + await ShouldComplete(collector.DisposeAsync().AsTask(), TimeSpan.FromSeconds(5), + "DisposeAsync must not deadlock when StartAsync was never called"); - completed.Should().BeSameAs(disposeTask, "DisposeAsync must not deadlock when StartAsync was never called"); - await disposeTask; - collector.Warnings.Should().Be(1); - collector.OffendingFiles.Should().Contain("file.yaml", "Dispose must drain queued diagnostics so outputs and OffendingFiles still observe them"); - output.Items.Should().HaveCount(1); + collector.Warnings.Should().Be(1, "severity counters update regardless of reader state"); + collector.IsStarted.Should().BeFalse(); + collector.OffendingFiles.Should().BeEmpty("writes are gated when no reader will drain them"); + output.Items.Should().BeEmpty(); } [Fact] - public async Task StopAsync_WithoutStartAsync_DoesNotHang() + public async Task StopAsync_WithoutStartAsyncAfterEmit_DoesNotHang() { var output = new RecordingOutput(); var collector = new DiagnosticsCollector([output]); collector.EmitError("file.yaml", "test error that nobody is reading"); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var stopTask = collector.StopAsync(CancellationToken.None); - var completed = await Task.WhenAny(stopTask, Task.Delay(Timeout.Infinite, cts.Token)); + await ShouldComplete(collector.StopAsync(CancellationToken.None), TimeSpan.FromSeconds(5), + "StopAsync must not deadlock when StartAsync was never called"); - completed.Should().BeSameAs(stopTask, "StopAsync must not deadlock when StartAsync was never called"); - await stopTask; collector.Errors.Should().Be(1); - output.Items.Should().HaveCount(1, "Stop must drain queued diagnostics to outputs even without a background reader"); + collector.IsStarted.Should().BeFalse(); + output.Items.Should().BeEmpty(); + } + + [Fact] + public async Task DisposeAsync_WithoutStartAsyncAndNoEmissions_DoesNotHang() + { + var collector = new DiagnosticsCollector([]); + + await ShouldComplete(collector.DisposeAsync().AsTask(), TimeSpan.FromSeconds(5), + "Instantiate-and-dispose with no emissions must be a no-op"); + + collector.IsStarted.Should().BeFalse(); + collector.Warnings.Should().Be(0); + collector.Errors.Should().Be(0); } [Fact] From fde5d282014fb8295d17aef5aee75487876efbef Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 20 May 2026 10:44:56 -0300 Subject: [PATCH 3/4] Set _readerStarted after WaitToWrite to require execution proof Per review: only flip the flag once the reader has cleared its initial setup, so it strictly means "the delegate executed past the first await". A canceled token or a Task.Run short-circuit leaves the flag false. Co-authored-by: Cursor --- .../Diagnostics/DiagnosticsCollector.cs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs index 863fd0329b..202633e63f 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs @@ -21,11 +21,12 @@ public class DiagnosticsCollector(IReadOnlyCollection output public int Hints => _hints; private Task? _started; - // True once the background reader delegate has actually begun executing. - // _started becoming non-null is not enough: Task.Run short-circuits to a - // canceled Task when given an already-canceled token, never running the - // delegate. We need to know the reader will actually drain the channel - // before we let writes accumulate in it. + // True once the background reader has passed its initial WaitToWrite setup + // and is about to enter the read loop. _started becoming non-null is not + // enough: Task.Run short-circuits to a canceled Task when given an + // already-canceled token, never running the delegate. Setting the flag + // only after WaitToWrite means we know the reader will actually drain + // the channel before we let writes accumulate in it. private volatile bool _readerStarted; public HashSet OffendingFiles { get; } = []; @@ -50,8 +51,8 @@ Task IHostedService.StartAsync(Cancel cancellationToken) return _started; _started = Task.Run(async () => { - _readerStarted = true; _ = await Channel.WaitToWrite(cancellationToken); + _readerStarted = true; while (!Channel.CancellationToken.IsCancellationRequested) { try From 22f897648e8ff25a98586d3fc2c5c60a30e3e928 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 20 May 2026 11:08:19 -0300 Subject: [PATCH 4/4] Fix StartAsync/Write race that dropped diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Write gate (`if (_readerStarted) Channel.Write(...)`) had a scheduling race: between StartAsync returning and the Task.Run delegate hitting `await Channel.WaitToWrite`, any emitted diagnostic was silently dropped. This broke the F# authoring suite, which synchronously schedules the reader and emits diagnostics before the reader had a chance to flip the flag. - Write unconditionally — UnboundedChannel never blocks, and items in an undrained channel are GC'd with the collector. - StopAsync now gates on `_started is null` (was StartAsync ever called?) rather than `_readerStarted` (is the reader executing?). Once StartAsync was called with a non-canceled token, awaiting `_started` is always meaningful: it completes when the reader exits after draining the channel. - IsStarted still backed by `_readerStarted` for accurate introspection (execution proof, per earlier review feedback). - Drop the leftover //TODO in DiagnosticsChannel.Write. Co-authored-by: Cursor --- .../Diagnostics/DiagnosticsChannel.cs | 12 +++------ .../Diagnostics/DiagnosticsCollector.cs | 27 +++++++++---------- .../DiagnosticsCollectorDisposeTests.cs | 4 +-- 3 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs index 24f28aa798..e1b2b0c3af 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsChannel.cs @@ -35,15 +35,9 @@ public void TryComplete(Exception? exception = null) public ValueTask WaitToWrite(Cancel ctx) => _channel.Writer.WaitToWriteAsync(ctx); // Unbounded channel: TryWrite only fails if the writer is completed, which - // means a producer raced past StopAsync/DisposeAsync. - public void Write(Diagnostic diagnostic) - { - var written = _channel.Writer.TryWrite(diagnostic); - if (!written) - { - //TODO - } - } + // means a producer raced past StopAsync/DisposeAsync. Drop silently — + // diagnostics must never throw back into the caller. + public void Write(Diagnostic diagnostic) => _ = _channel.Writer.TryWrite(diagnostic); public void Dispose() => _ctxSource.Dispose(); } diff --git a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs index e9f1a97ff1..4c8377b261 100644 --- a/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs +++ b/src/Elastic.Documentation/Diagnostics/DiagnosticsCollector.cs @@ -20,12 +20,11 @@ public class DiagnosticsCollector(IReadOnlyCollection output public int Hints => _hints; private Task? _started; - // True once the background reader has passed its initial WaitToWrite setup - // and is about to enter the read loop. _started becoming non-null is not - // enough: Task.Run short-circuits to a canceled Task when given an - // already-canceled token, never running the delegate. Setting the flag - // only after WaitToWrite means we know the reader will actually drain - // the channel before we let writes accumulate in it. + // True once the background reader delegate has actually begun executing. + // _started becoming non-null is not enough: Task.Run short-circuits to a + // canceled Task when given an already-canceled token, and the delegate + // never runs. StopAsync uses this to decide whether awaiting _started is + // meaningful or whether the channel is guaranteed to have no drainer. private volatile bool _readerStarted; public HashSet OffendingFiles { get; } = []; @@ -100,14 +99,16 @@ protected virtual void HandleItem(Diagnostic diagnostic) { } public virtual async Task StopAsync(Cancel cancellationToken) { Channel.TryComplete(); - // No reader was ever scheduled, so Write() gated everything out and the - // channel is empty — nothing to await, nothing to drain. - if (!_readerStarted) + // StartAsync was never called. Items may sit in the channel but + // nobody is coming to drain them — awaiting Channel.Reader.Completion + // here would deadlock. Returning is the correct behaviour for + // fire-and-forget collectors (the channel dies with the instance). + if (_started is null) return; try { - await _started!; + await _started; } catch (OperationCanceledException) { @@ -123,11 +124,7 @@ public virtual async Task StopAsync(Cancel cancellationToken) public virtual void Write(Diagnostic diagnostic) { IncrementSeverityCount(diagnostic); - // Severity counters are always accurate; the channel only matters if a - // reader will actually drain it. Skip the channel write otherwise so - // items don't accumulate and StopAsync has nothing to wait for. - if (_readerStarted) - Channel.Write(diagnostic); + Channel.Write(diagnostic); } public void Emit(Severity severity, string file, string message) => diff --git a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs index 500b06bde7..f5649b25a2 100644 --- a/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs +++ b/tests/Elastic.Changelog.Tests/Changelogs/DiagnosticsCollectorDisposeTests.cs @@ -39,8 +39,8 @@ await ShouldComplete(collector.DisposeAsync().AsTask(), TimeSpan.FromSeconds(5), collector.Warnings.Should().Be(1, "severity counters update regardless of reader state"); collector.IsStarted.Should().BeFalse(); - collector.OffendingFiles.Should().BeEmpty("writes are gated when no reader will drain them"); - output.Items.Should().BeEmpty(); + collector.OffendingFiles.Should().BeEmpty("OffendingFiles is only populated by the background reader"); + output.Items.Should().BeEmpty("IDiagnosticsOutput sinks are only invoked by the background reader"); } [Fact]