Skip to content

Commit c506ab2

Browse files
committed
Updated CTS flow and added a rotating cache for NTStatus and HResults
1 parent 3a42ab2 commit c506ab2

8 files changed

Lines changed: 522 additions & 100 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// // Copyright (c) Microsoft Corporation.
2+
// // Licensed under the MIT License.
3+
4+
using EventLogExpert.Eventing.Helpers;
5+
6+
namespace EventLogExpert.Eventing.Tests.Helpers;
7+
8+
public sealed class ResolverMethodsTests
9+
{
10+
[Fact]
11+
public void GetErrorMessage_ShouldReturnConsistentResults()
12+
{
13+
// Arrange — use a well-known HRESULT (ERROR_SUCCESS = 0)
14+
const uint errorSuccess = 0;
15+
16+
// Act
17+
var result1 = ResolverMethods.GetErrorMessage(errorSuccess);
18+
var result2 = ResolverMethods.GetErrorMessage(errorSuccess);
19+
20+
// Assert — same string returned (cached)
21+
Assert.NotNull(result1);
22+
Assert.Equal(result1, result2);
23+
}
24+
25+
[Fact]
26+
public void GetErrorMessage_WhenUnknownCode_ShouldReturnHexFallback()
27+
{
28+
// Arrange — use a code unlikely to have a system message
29+
const uint unknownCode = 0xDEADBEEF;
30+
31+
// Act
32+
var result = ResolverMethods.GetErrorMessage(unknownCode);
33+
34+
// Assert — falls back to hex representation
35+
Assert.Equal("0xDEADBEEF", result);
36+
}
37+
38+
[Fact]
39+
public void GetNtStatusMessage_ShouldReturnConsistentResults()
40+
{
41+
// Arrange — STATUS_SUCCESS = 0
42+
const uint statusSuccess = 0;
43+
44+
// Act
45+
var result1 = ResolverMethods.GetNtStatusMessage(statusSuccess);
46+
var result2 = ResolverMethods.GetNtStatusMessage(statusSuccess);
47+
48+
// Assert
49+
Assert.NotNull(result1);
50+
Assert.Equal(result1, result2);
51+
}
52+
53+
[Fact]
54+
public void MaxCacheSize_ShouldBeReasonableBound()
55+
{
56+
// Assert — the cache limit is the expected constant
57+
Assert.Equal(4096, ResolverMethods.MaxCacheSize);
58+
}
59+
}

src/EventLogExpert.Eventing/Helpers/ResolverMethods.cs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ namespace EventLogExpert.Eventing.Helpers;
77

88
internal static class ResolverMethods
99
{
10-
private static readonly ConcurrentDictionary<uint, string> s_hResultCache = [];
11-
private static readonly ConcurrentDictionary<uint, string> s_ntStatusCache = [];
10+
internal const int MaxCacheSize = 4096;
11+
12+
private static ConcurrentDictionary<uint, string> s_hResultCache = new();
13+
private static ConcurrentDictionary<uint, string> s_ntStatusCache = new();
1214

1315
/// <summary>
1416
/// Resolves an HRESULT or Win32 error code to a human-readable string.
@@ -17,15 +19,44 @@ internal static class ResolverMethods
1719
/// Results are cached to avoid repeated P/Invoke calls.
1820
/// </summary>
1921
internal static string GetErrorMessage(uint hResult) =>
20-
s_hResultCache.GetOrAdd(hResult, static code =>
22+
GetOrAddBounded(ref s_hResultCache, hResult, static code =>
2123
NativeMethods.FormatSystemMessage(code) ??
2224
NativeMethods.FormatNtStatusMessage(code) ??
2325
$"0x{code:X8}");
2426

2527
/// <summary>Resolves an NTSTATUS code to a human-readable string.</summary>
2628
internal static string GetNtStatusMessage(uint ntStatus) =>
27-
s_ntStatusCache.GetOrAdd(ntStatus, static status =>
29+
GetOrAddBounded(ref s_ntStatusCache, ntStatus, static status =>
2830
NativeMethods.FormatNtStatusMessage(status) ??
2931
NativeMethods.FormatSystemMessage(status) ??
3032
$"0x{status:X8}");
33+
34+
/// <summary>
35+
/// Bounded cache lookup with atomic swap eviction. On a cache hit the entry is returned
36+
/// immediately regardless of cache size. On a miss, if the cache has reached
37+
/// <see cref="MaxCacheSize"/> the entire dictionary is atomically swapped with a fresh
38+
/// instance (only one thread performs the swap) before inserting the new entry.
39+
/// </summary>
40+
private static string GetOrAddBounded(
41+
ref ConcurrentDictionary<uint, string> cache,
42+
uint key,
43+
Func<uint, string> factory)
44+
{
45+
var snapshot = Volatile.Read(ref cache);
46+
47+
if (snapshot.TryGetValue(key, out var cached))
48+
{
49+
return cached;
50+
}
51+
52+
if (snapshot.Count < MaxCacheSize)
53+
{
54+
return Volatile.Read(ref cache).GetOrAdd(key, factory);
55+
}
56+
57+
var replacement = new ConcurrentDictionary<uint, string>();
58+
Interlocked.CompareExchange(ref cache, replacement, snapshot);
59+
60+
return Volatile.Read(ref cache).GetOrAdd(key, factory);
61+
}
3162
}

src/EventLogExpert.UI.Tests/Store/EventLog/EventLogEffectsTests.cs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,40 @@ public async Task HandleCloseLog_ShouldRemoveLogAndDispatchCloseAction()
138138
a.LogId == logId));
139139
}
140140

141+
[Fact]
142+
public async Task HandleCloseLog_WhenLastLog_ShouldClearResolverCache()
143+
{
144+
// Arrange — state has no active logs (reducer already removed the last one)
145+
var logId = EventLogId.Create();
146+
var (effects, mockDispatcher, mockLogWatcher, mockResolverCache, _) = CreateEffectsWithServices();
147+
var action = new EventLogAction.CloseLog(logId, Constants.LogNameTestLog);
148+
149+
// Act
150+
await effects.HandleCloseLog(action, mockDispatcher);
151+
152+
// Assert
153+
mockResolverCache.Received(1).ClearAll();
154+
}
155+
156+
[Fact]
157+
public async Task HandleCloseLog_WhenOtherLogsRemain_ShouldNotClearResolverCache()
158+
{
159+
// Arrange — state still has another active log
160+
var logData = new EventLogData(Constants.LogNameLog1, PathType.LogName, []);
161+
var activeLogs = ImmutableDictionary<string, EventLogData>.Empty
162+
.Add(Constants.LogNameLog1, logData);
163+
164+
var (effects, mockDispatcher, _, mockResolverCache, _) = CreateEffectsWithServices(activeLogs: activeLogs);
165+
var closingLogId = EventLogId.Create();
166+
var action = new EventLogAction.CloseLog(closingLogId, Constants.LogNameTestLog);
167+
168+
// Act
169+
await effects.HandleCloseLog(action, mockDispatcher);
170+
171+
// Assert
172+
mockResolverCache.DidNotReceive().ClearAll();
173+
}
174+
141175
[Fact]
142176
public async Task HandleLoadEvents_ShouldFilterAndDispatchUpdateTable()
143177
{

src/EventLogExpert.UI.Tests/Store/EventLog/EventLogStoreTests.cs

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ public void ReduceCloseLog_ShouldRemoveSpecifiedLog()
559559
}
560560

561561
[Fact]
562-
public void ReduceLoadEvents_ShouldUpdateLogWithEvents()
562+
public void ReduceLoadEvents_ShouldIsolateStateFromOriginalList()
563563
{
564564
// Arrange
565565
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
@@ -576,12 +576,16 @@ public void ReduceLoadEvents_ShouldUpdateLogWithEvents()
576576
// Act
577577
var newState = EventLogReducers.ReduceLoadEvents(state, action);
578578

579-
// Assert
579+
// ImmutableArray is inherently isolated — creating a new one doesn't affect the state
580+
var extendedEvents = events.Add(EventUtils.CreateTestEvent(300));
581+
582+
// Assert - state should not reflect the extension
580583
Assert.Equal(2, newState.ActiveLogs[Constants.LogNameTestLog].Events.Count);
584+
Assert.Equal(3, extendedEvents.Length);
581585
}
582586

583587
[Fact]
584-
public void ReduceLoadEvents_ShouldIsolateStateFromOriginalList()
588+
public void ReduceLoadEvents_ShouldUpdateLogWithEvents()
585589
{
586590
// Arrange
587591
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
@@ -598,12 +602,105 @@ public void ReduceLoadEvents_ShouldIsolateStateFromOriginalList()
598602
// Act
599603
var newState = EventLogReducers.ReduceLoadEvents(state, action);
600604

601-
// ImmutableArray is inherently isolated — creating a new one doesn't affect the state
602-
var extendedEvents = events.Add(EventUtils.CreateTestEvent(300));
603-
604-
// Assert - state should not reflect the extension
605+
// Assert
605606
Assert.Equal(2, newState.ActiveLogs[Constants.LogNameTestLog].Events.Count);
606-
Assert.Equal(3, extendedEvents.Length);
607+
}
608+
609+
[Fact]
610+
public void ReduceLoadEvents_WhenLogIdDoesNotMatch_ShouldReturnStateUnchanged()
611+
{
612+
// Arrange — open a log, then create stale logData with a different ID
613+
var state = new EventLogState();
614+
615+
state = EventLogReducers.ReduceOpenLog(state,
616+
new EventLogAction.OpenLog(Constants.LogNameTestLog, PathType.LogName));
617+
618+
// Create stale logData with a new ID (simulating a previous load instance)
619+
var staleLogData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
620+
var events = ImmutableArray.Create(EventUtils.CreateTestEvent(100));
621+
622+
// Act — stale LoadEvents with mismatched ID
623+
var newState = EventLogReducers.ReduceLoadEvents(state, new EventLogAction.LoadEvents(staleLogData, events));
624+
625+
// Assert — state unchanged, original log preserved with its ID and empty events
626+
var originalId = state.ActiveLogs[Constants.LogNameTestLog].Id;
627+
Assert.NotEqual(originalId, staleLogData.Id);
628+
Assert.Equal(originalId, newState.ActiveLogs[Constants.LogNameTestLog].Id);
629+
Assert.Empty(newState.ActiveLogs[Constants.LogNameTestLog].Events);
630+
}
631+
632+
[Fact]
633+
public void ReduceLoadEvents_WhenLogIdMatches_ShouldUpdateLog()
634+
{
635+
// Arrange
636+
var state = new EventLogState();
637+
638+
state = EventLogReducers.ReduceOpenLog(state,
639+
new EventLogAction.OpenLog(Constants.LogNameTestLog, PathType.LogName));
640+
641+
var logData = state.ActiveLogs[Constants.LogNameTestLog];
642+
var events = ImmutableArray.Create(EventUtils.CreateTestEvent(100));
643+
644+
// Act — LoadEvents with matching ID
645+
var newState = EventLogReducers.ReduceLoadEvents(state, new EventLogAction.LoadEvents(logData, events));
646+
647+
// Assert — events applied
648+
Assert.Single(newState.ActiveLogs[Constants.LogNameTestLog].Events);
649+
}
650+
651+
[Fact]
652+
public void ReduceLoadEvents_WhenLogNotInActiveLogs_ShouldReturnStateUnchanged()
653+
{
654+
// Arrange — no logs open
655+
var state = new EventLogState();
656+
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
657+
var events = ImmutableArray.Create(EventUtils.CreateTestEvent(100));
658+
659+
// Act — stale LoadEvents arrives for a closed log
660+
var newState = EventLogReducers.ReduceLoadEvents(state, new EventLogAction.LoadEvents(logData, events));
661+
662+
// Assert — state unchanged, log NOT resurrected
663+
Assert.Same(state, newState);
664+
Assert.Empty(newState.ActiveLogs);
665+
}
666+
667+
[Fact]
668+
public void ReduceLoadEventsPartial_WhenLogIdDoesNotMatch_ShouldReturnStateUnchanged()
669+
{
670+
// Arrange
671+
var state = new EventLogState();
672+
673+
state = EventLogReducers.ReduceOpenLog(state,
674+
new EventLogAction.OpenLog(Constants.LogNameTestLog, PathType.LogName));
675+
676+
var staleLogData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
677+
var events = ImmutableArray.Create(EventUtils.CreateTestEvent(100));
678+
679+
// Act — stale partial with mismatched ID
680+
var newState = EventLogReducers.ReduceLoadEventsPartial(state,
681+
new EventLogAction.LoadEventsPartial(staleLogData, events));
682+
683+
// Assert — state unchanged, original log preserved with its ID
684+
var originalId = state.ActiveLogs[Constants.LogNameTestLog].Id;
685+
Assert.NotEqual(originalId, staleLogData.Id);
686+
Assert.Equal(originalId, newState.ActiveLogs[Constants.LogNameTestLog].Id);
687+
Assert.Empty(newState.ActiveLogs[Constants.LogNameTestLog].Events);
688+
}
689+
690+
[Fact]
691+
public void ReduceLoadEventsPartial_WhenLogNotInActiveLogs_ShouldReturnStateUnchanged()
692+
{
693+
// Arrange — no logs open
694+
var state = new EventLogState();
695+
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
696+
var events = ImmutableArray.Create(EventUtils.CreateTestEvent(100));
697+
698+
// Act
699+
var newState = EventLogReducers.ReduceLoadEventsPartial(state,
700+
new EventLogAction.LoadEventsPartial(logData, events));
701+
702+
// Assert
703+
Assert.Same(state, newState);
607704
}
608705

609706
[Fact]

src/EventLogExpert.UI.Tests/Store/EventTable/EventTableStoreTests.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,21 @@ public void ReduceSetActiveTable_WhenTableIsNotLoading_ShouldChangeActive()
673673
Assert.Equal(logData2.Id, newState.ActiveEventLogId);
674674
}
675675

676+
[Fact]
677+
public void ReduceSetActiveTable_WhenTableNotFound_ShouldReturnStateUnchanged()
678+
{
679+
// Arrange
680+
var state = new EventTableState();
681+
var staleLogId = EventLogId.Create();
682+
var action = new EventTableAction.SetActiveTable(staleLogId);
683+
684+
// Act
685+
var newState = EventTableReducers.ReduceSetActiveTable(state, action);
686+
687+
// Assert
688+
Assert.Same(state, newState);
689+
}
690+
676691
[Fact]
677692
public void ReduceSetOrderBy_WithNewColumn_ShouldUpdateOrderBy()
678693
{
@@ -808,6 +823,24 @@ public void ReduceUpdateDisplayedEvents_ShouldUpdateNonCombinedTables()
808823
table => Assert.Single(table.DisplayedEvents));
809824
}
810825

826+
[Fact]
827+
public void ReduceUpdateDisplayedEvents_WhenTableNotInActiveLogs_ShouldPreserveTable()
828+
{
829+
// Arrange — add a table but provide ActiveLogs that don't include it
830+
var logData = new EventLogData(Constants.LogNameTestLog, PathType.LogName, []);
831+
var state = new EventTableState();
832+
state = EventTableReducers.ReduceAddTable(state, new EventTableAction.AddTable(logData));
833+
834+
var emptyActiveLogs = new Dictionary<EventLogId, IReadOnlyList<DisplayEventModel>>();
835+
var action = new EventTableAction.UpdateDisplayedEvents(emptyActiveLogs);
836+
837+
// Act — table not in ActiveLogs should be preserved as-is
838+
var newState = EventTableReducers.ReduceUpdateDisplayedEvents(state, action);
839+
840+
// Assert — table still exists, events unchanged
841+
Assert.Single(newState.EventTables);
842+
}
843+
811844
[Fact]
812845
public void ReduceUpdateTable_ShouldUpdateTableEvents()
813846
{
@@ -832,4 +865,20 @@ public void ReduceUpdateTable_ShouldUpdateTableEvents()
832865
Assert.Equal(2, updatedTable.DisplayedEvents.Count);
833866
Assert.False(updatedTable.IsLoading);
834867
}
868+
869+
[Fact]
870+
public void ReduceUpdateTable_WhenTableNotFound_ShouldReturnStateUnchanged()
871+
{
872+
// Arrange — empty state, no tables
873+
var state = new EventTableState();
874+
var staleLogId = EventLogId.Create();
875+
var events = new List<DisplayEventModel> { EventUtils.CreateTestEvent(100) };
876+
var action = new EventTableAction.UpdateTable(staleLogId, events);
877+
878+
// Act — stale UpdateTable for a non-existent table
879+
var newState = EventTableReducers.ReduceUpdateTable(state, action);
880+
881+
// Assert — state unchanged, no exception thrown
882+
Assert.Same(state, newState);
883+
}
835884
}

0 commit comments

Comments
 (0)