Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Next Next commit
perf: optimize TUnit.Mocks invocation, setup, verification, and callb…
…ack hot paths

- Lock-free FindMatchingSetup via copy-on-write snapshot (eliminates lock contention on every mock invocation)
- Eagerly initialize _callHistory and _behaviorLock (removes LazyInitializer overhead per call)
- Single-behavior fast path in GetNextBehavior (skips list + lock for common case)
- Single-pass CountAndMarkVerified in verification (avoids List allocation and double iteration)
- Replace LINQ with loops in FormatCall/FormatExpectedCall (avoids closure + iterator allocations)
  • Loading branch information
thomhurst committed Mar 30, 2026
commit 40edbfb537c3d22929f1a85a40b67db50fa4d3fb
108 changes: 54 additions & 54 deletions TUnit.Mocks/MockEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ public sealed class MockEngine<T> : IMockEngineAccess where T : class
{
private readonly Lock _setupLock = new();
private Dictionary<int, List<MethodSetup>>? _setupsByMember;
private ConcurrentQueue<CallRecord>? _callHistory;
private volatile Dictionary<int, MethodSetup[]>? _setupsSnapshot;
private volatile ConcurrentQueue<CallRecord> _callHistory = new();

private ConcurrentDictionary<string, object?>? _autoTrackValues;
private ConcurrentQueue<(string EventName, bool IsSubscribe)>? _eventSubscriptions;
Expand Down Expand Up @@ -127,6 +128,14 @@ public void AddSetup(MethodSetup setup)
}

list.Add(setup);

// Rebuild lock-free snapshot for the read path
var snapshot = new Dictionary<int, MethodSetup[]>(dict.Count);
foreach (var kvp in dict)
{
snapshot[kvp.Key] = kvp.Value.ToArray();
}
_setupsSnapshot = snapshot;
}
}

Expand Down Expand Up @@ -386,13 +395,8 @@ public bool TryHandleCallWithReturn<TReturn>(int memberId, string memberName, ob
/// </summary>
public IReadOnlyList<CallRecord> GetCallsFor(int memberId)
{
if (Volatile.Read(ref _callHistory) is not { } history)
{
return [];
}

var result = new List<CallRecord>();
foreach (var record in history)
foreach (var record in _callHistory)
{
if (record.MemberId == memberId)
{
Expand All @@ -407,7 +411,7 @@ public IReadOnlyList<CallRecord> GetCallsFor(int memberId)
/// </summary>
public IReadOnlyList<CallRecord> GetAllCalls()
{
return Volatile.Read(ref _callHistory)?.ToArray() ?? [];
return _callHistory.ToArray();
}

/// <summary>
Expand All @@ -416,13 +420,8 @@ public IReadOnlyList<CallRecord> GetAllCalls()
[EditorBrowsable(EditorBrowsableState.Never)]
public IReadOnlyList<CallRecord> GetUnverifiedCalls()
{
if (Volatile.Read(ref _callHistory) is not { } history)
{
return [];
}

var result = new List<CallRecord>();
foreach (var record in history)
foreach (var record in _callHistory)
{
if (!record.IsVerified)
{
Expand All @@ -438,20 +437,18 @@ public IReadOnlyList<CallRecord> GetUnverifiedCalls()
[EditorBrowsable(EditorBrowsableState.Never)]
public IReadOnlyList<MethodSetup> GetSetups()
{
lock (_setupLock)
var snapshot = _setupsSnapshot;
if (snapshot is null)
{
if (_setupsByMember is not { } setups)
{
return [];
}
return [];
}

var all = new List<MethodSetup>();
foreach (var list in setups.Values)
{
all.AddRange(list);
}
return all;
var all = new List<MethodSetup>();
foreach (var arr in snapshot.Values)
{
all.AddRange(arr);
}
return all;
}

/// <summary>
Expand Down Expand Up @@ -482,14 +479,11 @@ public Diagnostics.MockDiagnostics GetDiagnostics()
}

var unmatchedCalls = new List<CallRecord>();
if (Volatile.Read(ref _callHistory) is { } history)
foreach (var call in _callHistory)
{
foreach (var call in history)
if (call.IsUnmatched)
{
if (call.IsUnmatched)
{
unmatchedCalls.Add(call);
}
unmatchedCalls.Add(call);
}
}

Expand Down Expand Up @@ -518,11 +512,12 @@ public void Reset()
lock (_setupLock)
{
_setupsByMember = null;
_setupsSnapshot = null;
_currentState = null;
PendingRequiredState = null;
}

Volatile.Write(ref _callHistory, null);
_callHistory = new ConcurrentQueue<CallRecord>();
Volatile.Write(ref _autoTrackValues, null);
Volatile.Write(ref _eventSubscriptions, null);
Volatile.Write(ref _onSubscribeCallbacks, null);
Expand Down Expand Up @@ -609,7 +604,7 @@ private CallRecord RecordCall(int memberId, string memberName, object?[] args)
{
var seq = MockCallSequence.Next();
var record = new CallRecord(memberId, memberName, args, seq);
LazyInitializer.EnsureInitialized(ref _callHistory)!.Enqueue(record);
_callHistory.Enqueue(record);
return record;
}

Expand All @@ -626,35 +621,36 @@ private void RaiseEventsForSetup(MethodSetup setup)

private (bool SetupFound, IBehavior? Behavior, MethodSetup? Setup) FindMatchingSetup(int memberId, object?[] args)
{
lock (_setupLock)
var snapshot = _setupsSnapshot;
if (snapshot is null || !snapshot.TryGetValue(memberId, out var setups))
{
return (false, null, null);
}

// Iterate last-added-first to implement "last wins" semantics
for (int i = setups.Length - 1; i >= 0; i--)
{
if (_setupsByMember is not { } setupDict || !setupDict.TryGetValue(memberId, out var setups))
var setup = setups[i];

// State guard: skip setups that require a different state
if (setup.RequiredState is not null && setup.RequiredState != Volatile.Read(ref _currentState))
{
return (false, null, null);
continue;
}

// Iterate last-added-first to implement "last wins" semantics
for (int i = setups.Count - 1; i >= 0; i--)
if (setup.Matches(args))
{
var setup = setups[i];

// State guard: skip setups that require a different state
if (setup.RequiredState is not null && setup.RequiredState != _currentState)
{
continue;
}

if (setup.Matches(args))
setup.IncrementInvokeCount();
setup.ApplyCaptures(args);
// Apply state transition under lock to prevent data races on _currentState
if (setup.TransitionTarget is not null)
{
setup.IncrementInvokeCount();
setup.ApplyCaptures(args);
// Apply state transition inside the lock to prevent data races on _currentState
if (setup.TransitionTarget is not null)
lock (_setupLock)
{
_currentState = setup.TransitionTarget;
}
return (true, setup.GetNextBehavior(), setup);
}
return (true, setup.GetNextBehavior(), setup);
}
}

Expand All @@ -663,7 +659,11 @@ private void RaiseEventsForSetup(MethodSetup setup)

private static string FormatCall(string memberName, object?[] args)
{
var formattedArgs = string.Join(", ", args.Select(a => a?.ToString() ?? "null"));
return $"{memberName}({formattedArgs})";
var descriptions = new string[args.Length];
for (int i = 0; i < args.Length; i++)
{
descriptions[i] = args[i]?.ToString() ?? "null";
}
return $"{memberName}({string.Join(", ", descriptions)})";
}
}
49 changes: 32 additions & 17 deletions TUnit.Mocks/Setup/MethodSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ namespace TUnit.Mocks.Setup;
public sealed class MethodSetup
{
private readonly IArgumentMatcher[] _matchers;
private Lock? _behaviorLock;
private readonly Lock _behaviorLock = new();
/// <summary>Fast path for the common single-behavior case. Avoids list + lock on read.</summary>
private IBehavior? _singleBehavior;
private List<IBehavior>? _behaviors;
private List<EventRaiseInfo>? _eventRaises;
private EventRaiseInfo[]? _eventRaisesSnapshot;
private Dictionary<int, object?>? _outRefAssignments;
private int _callIndex;

private Lock EnsureBehaviorLock() => LazyInitializer.EnsureInitialized(ref _behaviorLock)!;

public int MemberId { get; }

/// <summary>
Expand Down Expand Up @@ -62,10 +62,24 @@ public MethodSetup(int memberId, IArgumentMatcher[] matchers, string memberName

public void AddBehavior(IBehavior behavior)
{
lock (EnsureBehaviorLock())
lock (_behaviorLock)
{
var list = _behaviors ??= new();
list.Add(behavior);
if (_singleBehavior is null && _behaviors is null)
{
// First behavior: store directly, no list needed
Volatile.Write(ref _singleBehavior, behavior);
return;
}

// Promote to list on second behavior — write list before clearing single
// so concurrent lock-free readers always find at least one path
if (_behaviors is null)
{
_behaviors = [_singleBehavior!];
}

_behaviors.Add(behavior);
Volatile.Write(ref _singleBehavior, null);
}
}

Expand All @@ -84,7 +98,7 @@ public bool Matches(object?[] actualArgs)

public void AddEventRaise(EventRaiseInfo raiseInfo)
{
lock (EnsureBehaviorLock())
lock (_behaviorLock)
{
var list = _eventRaises ??= new();
list.Add(raiseInfo);
Expand All @@ -105,7 +119,7 @@ public IReadOnlyList<EventRaiseInfo> GetEventRaises()
return snapshot;
}

lock (EnsureBehaviorLock())
lock (_behaviorLock)
{
return _eventRaisesSnapshot ??= _eventRaises!.ToArray();
}
Expand Down Expand Up @@ -134,7 +148,7 @@ public void ApplyCaptures(object?[] args)
/// <param name="value">The value to assign.</param>
public void SetOutRefValue(int paramIndex, object? value)
{
lock (EnsureBehaviorLock())
lock (_behaviorLock)
{
_outRefAssignments ??= new Dictionary<int, object?>();
_outRefAssignments[paramIndex] = value;
Expand All @@ -149,13 +163,7 @@ public void SetOutRefValue(int paramIndex, object? value)
{
get
{
var lck = Volatile.Read(ref _behaviorLock);
if (lck is null)
{
return null;
}

lock (lck)
lock (_behaviorLock)
{
return _outRefAssignments;
}
Expand All @@ -178,12 +186,19 @@ public string[] GetMatcherDescriptions()

public IBehavior? GetNextBehavior()
{
// Fast path: single behavior (most common case — no lock needed)
var single = Volatile.Read(ref _singleBehavior);
if (single is not null)
{
return single;
}

if (Volatile.Read(ref _behaviors) is null)
{
return null;
}

lock (EnsureBehaviorLock())
lock (_behaviorLock)
{
if (_behaviors is not { Count: > 0 } behaviors)
{
Expand Down
8 changes: 6 additions & 2 deletions TUnit.Mocks/Verification/CallRecord.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ public bool IsUnmatched

public string FormatCall()
{
var args = string.Join(", ", Arguments.Select(a => a?.ToString() ?? "null"));
return $"{MemberName}({args})";
var descriptions = new string[Arguments.Length];
for (int i = 0; i < Arguments.Length; i++)
{
descriptions[i] = Arguments[i]?.ToString() ?? "null";
}
return $"{MemberName}({string.Join(", ", descriptions)})";
}
}
Loading
Loading