Skip to content

Commit 1b44640

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

File tree

9 files changed

+554
-103
lines changed

9 files changed

+554
-103
lines changed

src/EventLogExpert.Eventing.Tests/EventResolvers/EventResolverBaseTests.cs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,19 +1137,34 @@ public void ResolveEvent_WithNormalMismatch_ShouldStillReject()
11371137
public void ResolveEvent_WithNtStatusOutType_ShouldFallbackToHexForUnknownCodes()
11381138
{
11391139
// Arrange - Unknown NTStatus should show hex
1140+
const uint unknownCode = 0xDEADBEEF;
1141+
11401142
var (details, eventRecord) = EventUtils.CreateModernEvent(
11411143
description: "Status: %1",
11421144
template: """<template><data name="Status" inType="win:UInt32" outType="win:NTStatus"/></template>""",
1143-
properties: [(uint)0xDEADBEEF]);
1145+
properties: [unknownCode]);
11441146

11451147
var resolver = new TestEventResolver([details]);
11461148

11471149
// Act
11481150
var displayEvent = resolver.ResolveEvent(eventRecord);
11491151

1150-
// Assert
1152+
// Assert — if the OS resolves this code to a message, the description
1153+
// won't contain the hex fallback. Only assert hex when the system has
1154+
// no message for this code (avoids environment-dependent failures).
11511155
Assert.NotNull(displayEvent);
1152-
Assert.Contains("0xDEADBEEF", displayEvent.Description);
1156+
1157+
var ntStatusMessage = NativeMethods.FormatNtStatusMessage(unknownCode);
1158+
var systemMessage = NativeMethods.FormatSystemMessage(unknownCode);
1159+
1160+
if (ntStatusMessage is null && systemMessage is null)
1161+
{
1162+
Assert.Contains("0xDEADBEEF", displayEvent.Description);
1163+
}
1164+
else
1165+
{
1166+
Assert.NotNull(displayEvent.Description);
1167+
}
11531168
}
11541169

11551170
[Fact]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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 — if the OS doesn't resolve this code, we get the hex fallback.
35+
// On some OS versions/locales FormatMessage may resolve it, so only assert
36+
// the hex representation when the system has no message for this code.
37+
var systemMessage = NativeMethods.FormatSystemMessage(unknownCode);
38+
var ntStatusMessage = NativeMethods.FormatNtStatusMessage(unknownCode);
39+
40+
if (systemMessage is null && ntStatusMessage is null)
41+
{
42+
Assert.Equal("0xDEADBEEF", result);
43+
}
44+
else
45+
{
46+
Assert.NotNull(result);
47+
Assert.NotEqual(string.Empty, result);
48+
}
49+
}
50+
51+
[Fact]
52+
public void GetNtStatusMessage_ShouldReturnConsistentResults()
53+
{
54+
// Arrange — STATUS_SUCCESS = 0
55+
const uint statusSuccess = 0;
56+
57+
// Act
58+
var result1 = ResolverMethods.GetNtStatusMessage(statusSuccess);
59+
var result2 = ResolverMethods.GetNtStatusMessage(statusSuccess);
60+
61+
// Assert
62+
Assert.NotNull(result1);
63+
Assert.Equal(result1, result2);
64+
}
65+
66+
[Fact]
67+
public void MaxCacheSize_ShouldBeReasonableBound()
68+
{
69+
// Assert — the cache limit is the expected constant
70+
Assert.Equal(4096, ResolverMethods.MaxCacheSize);
71+
}
72+
}

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]

0 commit comments

Comments
 (0)