Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
c36f285
wip
Feb 12, 2025
c248a7c
Merge remote-tracking branch 'orig-dotnet-extensions/main' into twies…
Mar 21, 2025
1b4ccdf
refactor
Mar 24, 2025
2ae8401
Merge remote-tracking branch 'orig-dotnet-extensions/main' into twies…
Mar 24, 2025
721ce3e
we might actually need timeout too
Mar 24, 2025
233e23a
WIP
Mar 24, 2025
38c1d9a
timeout capability
Mar 31, 2025
84a5a3c
separate test cases into separate tests
Mar 31, 2025
32d426e
cleanup
Mar 31, 2025
27142fb
Merge remote-tracking branch 'orig-dotnet-extensions/main' into twies…
Mar 31, 2025
1445427
Merge remote-tracking branch 'orig-dotnet-extensions/main'
Mar 31, 2025
26c6852
Merge branch 'main' into twiesner/5752_FakeLogCollector_waiting_diffe…
Mar 31, 2025
562db95
cleanup
Mar 31, 2025
aa9be3f
stackallock threshold + test run infrastructure timeout
May 15, 2025
9332616
fake time provider based tests instead of real time
May 16, 2025
45d9eaf
enriching test cases by logs before awaiting and between interesting …
May 20, 2025
4dec848
Merge remote-tracking branch 'dotnet-extensions/main' into twiesner/5…
Aug 18, 2025
985ed46
continue with enumeration
Aug 20, 2025
5ece729
wip
Aug 20, 2025
64a2775
wip
Aug 20, 2025
da4dc79
wip
Aug 21, 2025
efc4324
Merge branch 'twiesner/5752_refactors' into twiesner/5752_FakeLogColl…
Aug 21, 2025
367bae6
removing previous implementation of easier review
Aug 21, 2025
eb342f3
old waiting logic removal
Aug 21, 2025
7c4b5d6
minor test cleanup
Aug 21, 2025
a92bf7a
using retrieved index in test
Aug 21, 2025
a6f4f2e
guarding against concurrent movenextasync calls
Aug 21, 2025
1cc522a
minor tweaks
Aug 21, 2025
9c79cda
wip
Aug 21, 2025
b2fe7ee
adjust enumerator index on clear
Aug 21, 2025
1c4c5a9
demo tests
Aug 21, 2025
3c9c668
extend demo test to better illustrate
Aug 22, 2025
2d95552
remove timeout param
Aug 26, 2025
75b5153
remove startingIndex param support
Aug 26, 2025
77d6993
discarding the Clear(count) functionality
Aug 26, 2025
fd74ce6
count param
Aug 28, 2025
9286930
issue with concurrent Clear call and maxItems
Aug 28, 2025
c1baae9
count removed
Sep 8, 2025
9a358ce
minor changes
Sep 8, 2025
255ac3e
Merge remote-tracking branch 'dotnet-extensions/main' into twiesner/5…
Jan 6, 2026
62152ac
fixes
Jan 6, 2026
0abc130
test
Jan 13, 2026
c31730b
Update test/Libraries/Microsoft.Extensions.Diagnostics.Testing.Tests/…
Demo30 Jan 13, 2026
64b63df
Update src/Libraries/Microsoft.Extensions.Diagnostics.Testing/Logging…
Demo30 Jan 13, 2026
b19b719
modified message
Jan 13, 2026
5c40845
checking disposal in thread-safe manner
Jan 13, 2026
3e692b7
fixed interlocked for net462 target; volatile reading of _disposed
Jan 13, 2026
b489573
volatile read on _recordCollectionVersion field
Jan 13, 2026
6c22cd6
_recordCollectionVersion volatile everywhere
Jan 13, 2026
5b5af68
Clear during wait test
Jan 13, 2026
fb1aa79
clearing up analyzer warnings
Jan 13, 2026
0fadd0a
reliable tests
Jan 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Extensions.Logging.Testing;

public partial class FakeLogCollector
{
private volatile int _recordCollectionVersion;

private TaskCompletionSource<object?> _logEnumerationSharedWaiter =
new(TaskCreationOptions.RunContinuationsAsynchronously);

private int _waitingEnumeratorCount;

Comment thread
Demo30 marked this conversation as resolved.
/// <summary>
/// Asynchronously enumerates the <see cref="FakeLogRecord"/> instances collected by this <see cref="FakeLogCollector"/>.
/// </summary>
/// <param name="cancellationToken">
/// A token that can be used to cancel the asynchronous enumeration. This token is observed while creating
/// and iterating the asynchronous sequence.
/// </param>
/// <returns>
/// An <see cref="IAsyncEnumerable{T}"/> that yields <see cref="FakeLogRecord"/> instances as they are written.
/// The sequence does not have a completion state defined and awaits subsequent elements indefinitely,
/// or stops when cancellation is requested.
/// </returns>
/// <remarks>
/// The returned sequence is <c>hot</c>: it streams log records as they become available and may block between
/// elements while waiting for additional logs to be written. Multiple independent enumerations can be created
/// by calling this method multiple times.
/// </remarks>
/// <exception cref="OperationCanceledException">
/// Thrown when the provided <paramref name="cancellationToken"/> or the enumerator's own cancellation token
/// is canceled while waiting for the next log record.
/// </exception>
/// <example>
/// The following example shows how to consume logs asynchronously:
/// <code language="csharp"><![CDATA[
/// var collector = new FakeLogCollector();
/// using var cts = new CancellationTokenSource();
///
/// await foreach (var record in collector.GetLogsAsync(cts.Token))
/// {
/// Console.WriteLine($"{record.Level}: {record.Message}");
/// }
/// ]]></code>
/// </example>
[Experimental(DiagnosticIds.Experiments.Telemetry)]
public IAsyncEnumerable<FakeLogRecord> GetLogsAsync(CancellationToken cancellationToken = default)
=> new LogAsyncEnumerable(this, cancellationToken);

private sealed class LogAsyncEnumerable : IAsyncEnumerable<FakeLogRecord>
{
private readonly FakeLogCollector _collector;
private readonly CancellationToken _enumerableCancellationToken;

internal LogAsyncEnumerable(
FakeLogCollector collector,
CancellationToken enumerableCancellationToken)
{
_collector = collector;
_enumerableCancellationToken = enumerableCancellationToken;
}

public IAsyncEnumerator<FakeLogRecord> GetAsyncEnumerator(
CancellationToken enumeratorCancellationToken = default)
=> new StreamEnumerator(_collector, _enumerableCancellationToken, enumeratorCancellationToken);
}

private sealed class StreamEnumerator : IAsyncEnumerator<FakeLogRecord>
{
private readonly FakeLogCollector _collector;
private readonly CancellationTokenSource _mainCts;
private int _index;
private int _disposed; // 0 = false, 1 = true (int type used for net462 compatibility)
private int _observedRecordCollectionVersion;

// Concurrent MoveNextAsync guard
private int _moveNextActive; // 0 = inactive, 1 = active (int type used for net462 compatibility)

public StreamEnumerator(
FakeLogCollector collector,
CancellationToken enumerableCancellationToken,
CancellationToken enumeratorCancellationToken)
{
_collector = collector;
_mainCts = enumerableCancellationToken.CanBeCanceled || enumeratorCancellationToken.CanBeCanceled
? CancellationTokenSource.CreateLinkedTokenSource(enumerableCancellationToken, enumeratorCancellationToken)
: new CancellationTokenSource();
_observedRecordCollectionVersion = collector._recordCollectionVersion;
Comment thread
Demo30 marked this conversation as resolved.
Comment thread
Demo30 marked this conversation as resolved.
}

public FakeLogRecord Current
{
get => field ?? throw new InvalidOperationException("Enumeration not started.");
private set;
}
Comment thread
Demo30 marked this conversation as resolved.

public async ValueTask<bool> MoveNextAsync()
{
if (Interlocked.CompareExchange(ref _moveNextActive, 1, 0) == 1)
{
throw new InvalidOperationException("MoveNextAsync is already in progress. Concurrent calls are not allowed.");
}

try
{
ThrowIfDisposed();

var mainCancellationToken = _mainCts.Token;

mainCancellationToken.ThrowIfCancellationRequested();

while (true)
{
TaskCompletionSource<object?>? waiter = null;

try
{
mainCancellationToken.ThrowIfCancellationRequested();

lock (_collector._records)
{
int currentVersion = _collector._recordCollectionVersion;
if (_observedRecordCollectionVersion != currentVersion)
{
_index = 0; // based on assumption that version changed on full collection clear
_observedRecordCollectionVersion = currentVersion;
}

if (_index < _collector._records.Count)
{
Current = _collector._records[_index++];
return true;
}

// waiter needs to be subscribed within records lock
// if not: more records could be added in the meantime and the waiter could be stuck waiting even though the index is behind the actual count
waiter = _collector._logEnumerationSharedWaiter;
_collector._waitingEnumeratorCount++;
}

// After the wait is complete in normal flow, no need to decrement because the shared waiter will be swapped and counter reset.
_ = await waiter.Task.WaitAsync(mainCancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (waiter is not null)
{
lock (_collector._records)
{
if (
_collector._waitingEnumeratorCount > 0 // counter can be zero during the cancellation path
&& waiter == _collector._logEnumerationSharedWaiter // makes sure we adjust the counter for the same shared waiting session
)
{
_collector._waitingEnumeratorCount--;
}
}
}

throw;
}
}

}
finally
{
Volatile.Write(ref _moveNextActive, 0);
}
}

#if !NETCOREAPP
public ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1)
{
return default;
}

_mainCts.Cancel();
_mainCts.Dispose();

return default;
Comment thread
Demo30 marked this conversation as resolved.
}
#else
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1)
{
return;
}

await _mainCts.CancelAsync().ConfigureAwait(false);
_mainCts.Dispose();
}
#endif

private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) == 1)
{
throw new ObjectDisposedException(nameof(StreamEnumerator));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Microsoft.Shared.Diagnostics;

Expand All @@ -14,7 +16,7 @@ namespace Microsoft.Extensions.Logging.Testing;
/// </summary>
[DebuggerDisplay("Count = {Count}, LatestRecord = {LatestRecord}")]
[DebuggerTypeProxy(typeof(FakeLogCollectorDebugView))]
public class FakeLogCollector
public partial class FakeLogCollector
{
private readonly List<FakeLogRecord> _records = [];
private readonly FakeLogCollectorOptions _options;
Expand Down Expand Up @@ -53,10 +55,16 @@ public void Clear()
{
lock (_records)
{
_records.Clear();
ClearRecordsCore();
}
}

private void ClearRecordsCore()
{
_records.Clear();
_ = Interlocked.Increment(ref _recordCollectionVersion);
}

/// <summary>
/// Gets the records that are held by the collector.
/// </summary>
Expand All @@ -71,7 +79,7 @@ public IReadOnlyList<FakeLogRecord> GetSnapshot(bool clear = false)
var records = _records.ToArray();
if (clear)
{
_records.Clear();
ClearRecordsCore();
}

return records;
Expand Down Expand Up @@ -136,11 +144,23 @@ internal void AddRecord(FakeLogRecord record)
return;
}

TaskCompletionSource<object?>? logEnumerationSharedWaiterToWake = null;

lock (_records)
{
_records.Add(record);

if (_waitingEnumeratorCount > 0)
{
logEnumerationSharedWaiterToWake = _logEnumerationSharedWaiter;
_logEnumerationSharedWaiter = new TaskCompletionSource<object?>(TaskCreationOptions.RunContinuationsAsynchronously);
_waitingEnumeratorCount = 0;
}
}

// it is possible the task was already completed, but it does not matter and we can avoid locking
_ = logEnumerationSharedWaiterToWake?.TrySetResult(null);

_options.OutputSink?.Invoke(_options.OutputFormatter(record));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
<InjectTaskWaitAsyncOnLegacy>true</InjectTaskWaitAsyncOnLegacy>
<NoWarn>$(NoWarn);SYSLIB1100;SYSLIB1101</NoWarn>
</PropertyGroup>

Expand Down
Loading
Loading