diff --git a/.github/workflows/PullRequest.yml b/.github/workflows/PullRequest.yml index 9da775a57..bc2f18fcb 100644 --- a/.github/workflows/PullRequest.yml +++ b/.github/workflows/PullRequest.yml @@ -26,7 +26,7 @@ jobs: - name: Restore dependencies run: dotnet restore EventLogExpert.slnx - name: Build - run: dotnet build EventLogExpert.slnx --no-restore -c Release -p:PublishReadyToRun=false -m:1 + run: dotnet build EventLogExpert.slnx --no-restore -c Release -p:PublishReadyToRun=false -p:WindowsPackageType=None - name: Test (unit) shell: pwsh run: | diff --git a/Directory.Build.props b/Directory.Build.props index 64a91cc37..37a2d8332 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ enable enable true + embedded $(NoWarn);CsWinRT1030 diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 000000000..b7fb0e0b0 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,7 @@ + + + + $(GlobalPropertiesToRemoveFromProjectReferences);RuntimeIdentifier;SelfContained + + + diff --git a/EventLogExpert.slnx b/EventLogExpert.slnx index 6653bd557..7c20d77a0 100644 --- a/EventLogExpert.slnx +++ b/EventLogExpert.slnx @@ -48,14 +48,11 @@ - - - diff --git a/compose.yml b/compose.yml index d99da095b..c22a02e18 100644 --- a/compose.yml +++ b/compose.yml @@ -39,13 +39,6 @@ services: - --logger - "trx;LogFileName=runtime.trx" - eventdbtool: - <<: *base - command: - - tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/EventLogExpert.EventDbTool.IntegrationTests.csproj - - --logger - - "trx;LogFileName=eventdbtool.trx" - elevationhelper: <<: *base command: diff --git a/docs/Explorer-Context-Menu.md b/docs/Explorer-Context-Menu.md index d87ae9059..1c12975ee 100644 --- a/docs/Explorer-Context-Menu.md +++ b/docs/Explorer-Context-Menu.md @@ -29,7 +29,7 @@ The MAUI receiving side: ## Build prerequisites -The native shell extension requires the **MSVC C++ workload** to build. The **Windows 10/11 SDK (10.0.26100+)** it compiles against is delivered as NuGet packages (`Microsoft.Windows.SDK.CPP` + `Microsoft.Windows.SDK.CPP.x64`, restored to `src/packages`), so a machine-installed Windows SDK is **not** required on build agents — the OneBranch release container ships the C++ toolset but no SDK, and the packages supply the headers, import libs, winmd, and SDK tools (`rc.exe`, `mt.exe`, `midlrt.exe`). Specifically: +The native shell extension requires the **MSVC C++ workload** to build. The **Windows 10/11 SDK (10.0.26100+)** it compiles against is delivered as NuGet packages (`Microsoft.Windows.SDK.CPP` + the per-architecture `Microsoft.Windows.SDK.CPP.x64` / `Microsoft.Windows.SDK.CPP.arm64`, restored to `src/packages`), so a machine-installed Windows SDK is **not** required on build agents - the OneBranch release container ships the C++ toolset but no SDK, and the packages supply the headers, import libs, winmd, and SDK tools (`rc.exe`, `mt.exe`, `midlrt.exe`). Specifically: - Visual Studio 2026 (or 2022) with the **Desktop development with C++** workload - The Windows SDK NuGet packages above (restored automatically by the `BuildExplorerExtensionNative` target; see below). A locally-installed Windows SDK 10.0.26100+ is still honored as a fallback when those packages are not restored — the vcxproj's `WindowsTargetPlatformVersion` then stays a bare `10.0` (latest installed SDK). @@ -46,7 +46,7 @@ cd src/EventLogExpert.ExplorerExtensionNative nuget restore packages.config -PackagesDirectory ..\packages ``` -This populates `src/packages/` with `Microsoft.Windows.CppWinRT.*`, `Microsoft.Windows.ImplementationLibrary.*` (WIL), and the Windows SDK packages `Microsoft.Windows.SDK.CPP.*` + `Microsoft.Windows.SDK.CPP.x64.*` (the SDK packages are large — several hundred MB extracted). All are gitignored. +This populates `src/packages/` with `Microsoft.Windows.CppWinRT.*`, `Microsoft.Windows.ImplementationLibrary.*` (WIL), and the Windows SDK packages `Microsoft.Windows.SDK.CPP.*` + the per-architecture `Microsoft.Windows.SDK.CPP.x64.*` / `Microsoft.Windows.SDK.CPP.arm64.*` (the SDK packages are large - several hundred MB extracted). All are gitignored. ## Local dev install (signed MSIX) diff --git a/scripts/run-tests.ps1 b/scripts/run-tests.ps1 index f950eb364..9aeebc3e5 100644 --- a/scripts/run-tests.ps1 +++ b/scripts/run-tests.ps1 @@ -19,7 +19,7 @@ .EXAMPLE ./scripts/run-tests.ps1 ./scripts/run-tests.ps1 -Suite eventing - ./scripts/run-tests.ps1 -Suite runtime,eventdbtool + ./scripts/run-tests.ps1 -Suite runtime,elevationhelper #> [CmdletBinding()] param( diff --git a/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs index fa5fabc58..6e18265ff 100644 --- a/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs +++ b/src/EventLogExpert.EventDbTool/Properties/AssemblyInfo.cs @@ -3,5 +3,4 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("EventLogExpert.EventDbTool.IntegrationTests")] [assembly: InternalsVisibleTo("EventLogExpert.EventDbTool.Tests")] diff --git a/src/EventLogExpert.Eventing/Interop/EvtVariant.cs b/src/EventLogExpert.Eventing/Interop/EvtVariant.cs index 813b08196..5402f7475 100644 --- a/src/EventLogExpert.Eventing/Interop/EvtVariant.cs +++ b/src/EventLogExpert.Eventing/Interop/EvtVariant.cs @@ -8,28 +8,46 @@ namespace EventLogExpert.Eventing.Interop; [StructLayout(LayoutKind.Explicit)] internal readonly record struct EvtVariant { - [FieldOffset(0)] internal readonly uint UInteger; - [FieldOffset(0)] internal readonly int Integer; - [FieldOffset(0)] internal readonly byte UInt8; - [FieldOffset(0)] internal readonly short Short; - [FieldOffset(0)] internal readonly ushort UShort; - [FieldOffset(0)] internal readonly uint Bool; + [FieldOffset(0)] internal readonly int BooleanVal; + [FieldOffset(0)] internal readonly sbyte SByteVal; + [FieldOffset(0)] internal readonly short Int16Val; + [FieldOffset(0)] internal readonly int Int32Val; + [FieldOffset(0)] internal readonly long Int64Val; [FieldOffset(0)] internal readonly byte ByteVal; - [FieldOffset(0)] internal readonly byte SByte; - [FieldOffset(0)] internal readonly ulong ULong; - [FieldOffset(0)] internal readonly long Long; - [FieldOffset(0)] internal readonly float Single; - [FieldOffset(0)] internal readonly double Double; + [FieldOffset(0)] internal readonly ushort UInt16Val; + [FieldOffset(0)] internal readonly uint UInt32Val; + [FieldOffset(0)] internal readonly ulong UInt64Val; + [FieldOffset(0)] internal readonly float SingleVal; + [FieldOffset(0)] internal readonly double DoubleVal; + [FieldOffset(0)] internal readonly ulong FileTimeVal; + [FieldOffset(0)] internal readonly nint SysTimeVal; + [FieldOffset(0)] internal readonly nint GuidVal; [FieldOffset(0)] internal readonly nint StringVal; - [FieldOffset(0)] internal readonly nint AnsiString; + [FieldOffset(0)] internal readonly nint AnsiStringVal; + [FieldOffset(0)] internal readonly nint BinaryVal; [FieldOffset(0)] internal readonly nint SidVal; - [FieldOffset(0)] internal readonly nint Binary; - [FieldOffset(0)] internal readonly nint Reference; - [FieldOffset(0)] internal readonly nint Handle; - [FieldOffset(0)] internal readonly nint GuidReference; - [FieldOffset(0)] internal readonly ulong FileTime; - [FieldOffset(0)] internal readonly nint SystemTime; - [FieldOffset(0)] internal readonly nint SizeT; + [FieldOffset(0)] internal readonly nuint SizeTVal; + [FieldOffset(0)] internal readonly nint BooleanArr; + [FieldOffset(0)] internal readonly nint SByteArr; + [FieldOffset(0)] internal readonly nint Int16Arr; + [FieldOffset(0)] internal readonly nint Int32Arr; + [FieldOffset(0)] internal readonly nint Int64Arr; + [FieldOffset(0)] internal readonly nint ByteArr; + [FieldOffset(0)] internal readonly nint UInt16Arr; + [FieldOffset(0)] internal readonly nint UInt32Arr; + [FieldOffset(0)] internal readonly nint UInt64Arr; + [FieldOffset(0)] internal readonly nint SingleArr; + [FieldOffset(0)] internal readonly nint DoubleArr; + [FieldOffset(0)] internal readonly nint FileTimeArr; + [FieldOffset(0)] internal readonly nint SysTimeArr; + [FieldOffset(0)] internal readonly nint GuidArr; + [FieldOffset(0)] internal readonly nint StringArr; + [FieldOffset(0)] internal readonly nint AnsiStringArr; + [FieldOffset(0)] internal readonly nint SidArr; + [FieldOffset(0)] internal readonly nint SizeTArr; + [FieldOffset(0)] internal readonly nint EvtHandleVal; + [FieldOffset(0)] internal readonly nint XmlVal; + [FieldOffset(0)] internal readonly nint XmlValArr; [FieldOffset(8)] internal readonly uint Count; [FieldOffset(12)] internal readonly uint Type; } diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs index 7a121ad6d..f0d7d3a3e 100644 --- a/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.Evt.cs @@ -24,29 +24,29 @@ internal static partial class NativeMethods case (int)EvtVariantType.String: return Marshal.PtrToStringUni(variant.StringVal); case (int)EvtVariantType.AnsiString: - return Marshal.PtrToStringAnsi(variant.AnsiString); + return Marshal.PtrToStringAnsi(variant.AnsiStringVal); case (int)EvtVariantType.SByte: - return variant.SByte; + return variant.SByteVal; case (int)EvtVariantType.Byte: - return variant.UInt8; + return variant.ByteVal; case (int)EvtVariantType.Int16: - return variant.Short; + return variant.Int16Val; case (int)EvtVariantType.UInt16: - return variant.UShort; + return variant.UInt16Val; case (int)EvtVariantType.Int32: - return variant.Integer; + return variant.Int32Val; case (int)EvtVariantType.UInt32: - return variant.UInteger; + return variant.UInt32Val; case (int)EvtVariantType.Int64: - return variant.Long; + return variant.Int64Val; case (int)EvtVariantType.UInt64: - return variant.ULong; + return variant.UInt64Val; case (int)EvtVariantType.Single: - return variant.Single; + return variant.SingleVal; case (int)EvtVariantType.Double: - return variant.Double; + return variant.DoubleVal; case (int)EvtVariantType.Boolean: - return variant.Bool != 0; + return variant.BooleanVal != 0; case (int)EvtVariantType.Binary: if (variant.Count == 0) { @@ -55,23 +55,23 @@ internal static partial class NativeMethods int byteCount = CheckedCount(variant.Count, EvtVariantType.Binary); - if (variant.Binary == IntPtr.Zero) + if (variant.BinaryVal == IntPtr.Zero) { throw new InvalidDataException( $"Null reference with non-zero count {variant.Count} for {nameof(EvtVariantType)}.{EvtVariantType.Binary}"); } byte[] bytes = new byte[byteCount]; - Marshal.Copy(variant.Binary, bytes, 0, byteCount); + Marshal.Copy(variant.BinaryVal, bytes, 0, byteCount); return bytes; case (int)EvtVariantType.Guid: - return Marshal.PtrToStructure(variant.GuidReference); + return Marshal.PtrToStructure(variant.GuidVal); case (int)EvtVariantType.SizeT: - return variant.SizeT; + return variant.SizeTVal; case (int)EvtVariantType.FileTime: - return DateTime.FromFileTimeUtc((long)variant.FileTime); + return DateTime.FromFileTimeUtc((long)variant.FileTimeVal); case (int)EvtVariantType.SysTime: - var sysTime = Marshal.PtrToStructure(variant.SystemTime); + var sysTime = Marshal.PtrToStructure(variant.SysTimeVal); return new DateTime( sysTime.Year, @@ -85,13 +85,13 @@ internal static partial class NativeMethods case (int)EvtVariantType.Sid: return variant.SidVal == IntPtr.Zero ? null : new SecurityIdentifier(variant.SidVal); case (int)EvtVariantType.HexInt32: - return variant.Integer; + return variant.Int32Val; case (int)EvtVariantType.HexInt64: - return variant.ULong; + return variant.UInt64Val; case (int)EvtVariantType.Handle: - return new EvtHandle(variant.Handle); + return new EvtHandle(variant.EvtHandleVal); case (int)EvtVariantType.Xml: - return Marshal.PtrToStringUni(variant.StringVal); + return Marshal.PtrToStringUni(variant.XmlVal); case (int)EvtVariantType.StringArray: if (variant.Count == 0) { @@ -100,7 +100,7 @@ internal static partial class NativeMethods int stringCount = CheckedCount(variant.Count, EvtVariantType.StringArray); - if (variant.Reference == IntPtr.Zero) + if (variant.StringArr == IntPtr.Zero) { throw new InvalidDataException( $"Null reference with non-zero count {variant.Count} for {nameof(EvtVariantType)}.{EvtVariantType.StringArray}"); @@ -110,20 +110,20 @@ internal static partial class NativeMethods for (int i = 0; i < stringCount; i++) { - IntPtr stringRef = Marshal.ReadIntPtr(variant.Reference, i * IntPtr.Size); + IntPtr stringRef = Marshal.ReadIntPtr(variant.StringArr, i * IntPtr.Size); stringArray[i] = Marshal.PtrToStringAuto(stringRef) ?? string.Empty; } return stringArray; case (int)EvtVariantType.ByteArray: - return ReadBlittableArray(variant.Reference, variant.Count, EvtVariantType.ByteArray); + return ReadBlittableArray(variant.ByteArr, variant.Count, EvtVariantType.ByteArray); case (int)EvtVariantType.UInt16Array: - return ReadBlittableArray(variant.Reference, variant.Count, EvtVariantType.UInt16Array); + return ReadBlittableArray(variant.UInt16Arr, variant.Count, EvtVariantType.UInt16Array); case (int)EvtVariantType.UInt32Array: - return ReadBlittableArray(variant.Reference, variant.Count, EvtVariantType.UInt32Array); + return ReadBlittableArray(variant.UInt32Arr, variant.Count, EvtVariantType.UInt32Array); case (int)EvtVariantType.HexInt32Array: - return ReadBlittableArray(variant.Reference, variant.Count, EvtVariantType.HexInt32Array); + return ReadBlittableArray(variant.Int32Arr, variant.Count, EvtVariantType.HexInt32Array); default: throw new InvalidDataException($"Invalid {nameof(EvtVariantType)}: {variant.Type}"); } @@ -295,6 +295,19 @@ internal static partial EvtHandle EvtQuery( [MarshalAs(UnmanagedType.LPWStr)] string? query, LogPathType flags); + /// + /// EvtQuery overload that takes the raw combined query-flags (the path-type bits + /// ORed with direction flags such as EvtQueryReverseDirection 0x200), so a caller can opt into newest-first + /// reads. The typed binding remains the path for the default oldest-first reads and its other + /// callers (the watcher and the XML resolver). + /// + [LibraryImport(EventLogApi, EntryPoint = "EvtQuery", SetLastError = true)] + internal static partial EvtHandle EvtQueryWithFlags( + EvtHandle session, + [MarshalAs(UnmanagedType.LPWStr)] string path, + [MarshalAs(UnmanagedType.LPWStr)] string? query, + int flags); + /// Renders an XML fragment base on the render context that you specify [LibraryImport(EventLogApi, SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] @@ -395,6 +408,85 @@ internal static string FormatMessage(EvtHandle publisherMetadataHandle, uint mes } } + /// Converts a buffer that was returned from to an + /// + /// Pointer to a buffer returned from . Must stay pinned and valid for + /// the duration of the call; the variants are read directly through this pointer. + /// + /// Number of properties returned from + /// + internal static unsafe EventRecord GetEventRecord(IntPtr eventBuffer, int propertyCount) + { + EventRecord properties = new(); + + var variants = (EvtVariant*)eventBuffer; + + for (int i = 0; i < propertyCount; i++) + { + ref readonly var variant = ref variants[i]; + + // Properties are returned in the order defined in EVT_SYSTEM_PROPERTY_ID enum. Value-type + // properties are read straight from the typed union field (no boxing); the string, SID and + // time properties stay on ConvertVariant (no boxing benefit, and it handles the + // FileTime/SysTime split for TimeCreated). Unmapped indices are intentionally skipped. + switch (i) + { + case (int)EvtSystemPropertyId.ProviderName: + properties.ProviderName = (string)ConvertVariant(variant)!; + break; + case (int)EvtSystemPropertyId.EventId: + if (variant.Type != (uint)EvtVariantType.UInt16) + { + throw new InvalidDataException($"Expected EVT_VARIANT type UInt16 for EventId, got {variant.Type}."); + } + + properties.Id = variant.UInt16Val; + break; + case (int)EvtSystemPropertyId.Qualifiers: + properties.Qualifiers = ReadOptionalUInt16(variant); + break; + case (int)EvtSystemPropertyId.Level: + properties.Level = ReadOptionalByte(variant); + break; + case (int)EvtSystemPropertyId.Task: + properties.Task = ReadOptionalUInt16(variant); + break; + case (int)EvtSystemPropertyId.Keywords: + properties.Keywords = ReadOptionalInt64(variant); + break; + case (int)EvtSystemPropertyId.TimeCreated: + properties.TimeCreated = (DateTime)ConvertVariant(variant)!; + break; + case (int)EvtSystemPropertyId.EventRecordId: + properties.RecordId = ReadOptionalInt64(variant); + break; + case (int)EvtSystemPropertyId.ActivityId: + properties.ActivityId = ReadGuidOrNull(variant); + break; + case (int)EvtSystemPropertyId.ProcessID: + properties.ProcessId = ReadOptionalInt32(variant); + break; + case (int)EvtSystemPropertyId.ThreadID: + properties.ThreadId = ReadOptionalInt32(variant); + break; + case (int)EvtSystemPropertyId.Channel: + properties.LogName = (string)ConvertVariant(variant)!; + break; + case (int)EvtSystemPropertyId.Computer: + properties.ComputerName = (string)ConvertVariant(variant)!; + break; + case (int)EvtSystemPropertyId.UserID: + properties.UserId = (SecurityIdentifier?)ConvertVariant(variant); + break; + case (int)EvtSystemPropertyId.Version: + properties.Version = ReadOptionalByte(variant); + break; + } + } + + return properties; + } + internal static object GetObjectArrayProperty( EvtHandle array, int index, @@ -441,7 +533,7 @@ internal static object GetObjectArrayProperty( { fixed (char* bufferPtr = buffer) { - var variant = Marshal.PtrToStructure((IntPtr)bufferPtr); + var variant = *(EvtVariant*)bufferPtr; return ConvertVariant(variant) ?? throw new InvalidDataException($"Invalid Object Array for Property: {propertyId}"); @@ -581,15 +673,15 @@ internal static IReadOnlyList RenderEventProperties(EvtHandle eventHandl var properties = new object[propertyCount]; - for (int i = 0; i < propertyCount; i++) + unsafe { - unsafe + fixed (char* bufferPtr = buffer) { - fixed (char* bufferPtr = buffer) - { - var property = Marshal.PtrToStructure((IntPtr)bufferPtr + (i * Marshal.SizeOf())); + var variants = (EvtVariant*)bufferPtr; - properties[i] = ConvertVariant(property) ?? throw new InvalidDataException(); + for (int i = 0; i < propertyCount; i++) + { + properties[i] = ConvertVariant(variants[i]) ?? throw new InvalidDataException(); } } } @@ -698,73 +790,6 @@ private static int CheckedCount(uint count, EvtVariantType type) } } - /// Converts a buffer that was returned from to an - /// Pointer to a buffer returned from - /// Number of properties returned from - /// - private static EventRecord GetEventRecord(IntPtr eventBuffer, int propertyCount) - { - EventRecord properties = new(); - - for (int i = 0; i < propertyCount; i++) - { - var property = Marshal.PtrToStructure(eventBuffer + (i * Marshal.SizeOf())); - var variant = ConvertVariant(property); - - // Properties are returned in the order defined in EVT_SYSTEM_PROPERTY_ID enum - switch (i) - { - case (int)EvtSystemPropertyId.ActivityId: - properties.ActivityId = (Guid?)variant; - break; - case (int)EvtSystemPropertyId.Computer: - properties.ComputerName = (string)variant!; - break; - case (int)EvtSystemPropertyId.EventId: - properties.Id = (ushort)variant!; - break; - case (int)EvtSystemPropertyId.Qualifiers: - properties.Qualifiers = (ushort?)variant; - break; - case (int)EvtSystemPropertyId.Keywords: - properties.Keywords = (long?)(ulong?)variant; - break; - case (int)EvtSystemPropertyId.Level: - properties.Level = (byte?)variant; - break; - case (int)EvtSystemPropertyId.Channel: - properties.LogName = (string)variant!; - break; - case (int)EvtSystemPropertyId.ProcessID: - properties.ProcessId = (int?)(uint?)variant; - break; - case (int)EvtSystemPropertyId.EventRecordId: - properties.RecordId = (long?)(ulong?)variant; - break; - case (int)EvtSystemPropertyId.ProviderName: - properties.ProviderName = (string)variant!; - break; - case (int)EvtSystemPropertyId.Task: - properties.Task = (ushort?)variant; - break; - case (int)EvtSystemPropertyId.ThreadID: - properties.ThreadId = (int?)(uint?)variant; - break; - case (int)EvtSystemPropertyId.TimeCreated: - properties.TimeCreated = (DateTime)variant!; - break; - case (int)EvtSystemPropertyId.UserID: - properties.UserId = (SecurityIdentifier?)variant; - break; - case (int)EvtSystemPropertyId.Version: - properties.Version = (byte?)variant; - break; - } - } - - return properties; - } - private static unsafe T[] ReadBlittableArray(IntPtr reference, uint count, EvtVariantType type) where T : unmanaged { if (count == 0) @@ -785,4 +810,64 @@ private static unsafe T[] ReadBlittableArray(IntPtr reference, uint count, Ev return result; } + + private static unsafe Guid? ReadGuidOrNull(in EvtVariant variant) + { + if (variant.Type == (uint)EvtVariantType.Null) { return null; } + + if (variant.Type != (uint)EvtVariantType.Guid) + { + throw new InvalidDataException( + $"Expected {nameof(EvtVariantType)}.{EvtVariantType.Guid} for the activity id, got type {variant.Type}"); + } + + if (variant.GuidVal == 0) + { + throw new InvalidDataException($"Null {nameof(EvtVariantType)}.{EvtVariantType.Guid} pointer for the activity id"); + } + + return *(Guid*)variant.GuidVal; + } + + private static byte? ReadOptionalByte(in EvtVariant variant) + { + if (variant.Type == (uint)EvtVariantType.Null) { return null; } + + return variant.Type != (uint)EvtVariantType.Byte ? + throw new InvalidDataException($"Expected EVT_VARIANT type Byte, got {variant.Type}.") : + variant.ByteVal; + } + + private static int? ReadOptionalInt32(in EvtVariant variant) + { + if (variant.Type == (uint)EvtVariantType.Null) { return null; } + + if (variant.Type != (uint)EvtVariantType.UInt32) + { + throw new InvalidDataException($"Expected EVT_VARIANT type UInt32, got {variant.Type}."); + } + + return unchecked((int)variant.UInt32Val); + } + + private static long? ReadOptionalInt64(in EvtVariant variant) + { + if (variant.Type == (uint)EvtVariantType.Null) { return null; } + + if (variant.Type is not ((uint)EvtVariantType.UInt64 or (uint)EvtVariantType.HexInt64)) + { + throw new InvalidDataException($"Expected EVT_VARIANT type UInt64 or HexInt64, got {variant.Type}."); + } + + return unchecked((long)variant.UInt64Val); + } + + private static ushort? ReadOptionalUInt16(in EvtVariant variant) + { + if (variant.Type == (uint)EvtVariantType.Null) { return null; } + + return variant.Type != (uint)EvtVariantType.UInt16 ? + throw new InvalidDataException($"Expected EVT_VARIANT type UInt16, got {variant.Type}.") : + variant.UInt16Val; + } } diff --git a/src/EventLogExpert.Eventing/Interop/NativeMethods.cs b/src/EventLogExpert.Eventing/Interop/NativeMethods.cs index ddcde71ca..cdd674dd9 100644 --- a/src/EventLogExpert.Eventing/Interop/NativeMethods.cs +++ b/src/EventLogExpert.Eventing/Interop/NativeMethods.cs @@ -115,6 +115,9 @@ internal static partial LibraryHandle LoadLibraryExW( [LibraryImport(Kernel32Api)] internal static partial IntPtr LockResource(IntPtr hResData); + [LibraryImport(Kernel32Api, SetLastError = true)] + internal static partial uint SizeofResource(LibraryHandle hModule, IntPtr hResInfo); + private static string? FormatMessageFromModule(IntPtr moduleHandle, uint messageId) => FormatMessageWithRetry( FORMAT_MESSAGE_FROM_HMODULE | FORMAT_MESSAGE_IGNORE_INSERTS, diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs index fbc9ce2e0..5ba74a26c 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/EventMessageProvider.cs @@ -39,197 +39,40 @@ internal static List LoadMessagesFromFiles( foreach (var file in legacyProviderFiles) { - using LibraryHandle hModule = LoadMessageModule(file, logger); + if (!MessageTableReader.TryOpen(file, logger, out var handle, out nint memTable, out uint size)) { continue; } - if (hModule.IsInvalid) - { - continue; - } - - // FindResourceEx returns an HRSRC that points into the already-loaded module's - // resource section. It is owned by the module handle and must NOT be FreeLibrary'd. - IntPtr msgTableInfo = NativeMethods.FindResourceExA(hModule, NativeMethods.RT_MESSAGETABLE, 1); - int error = Marshal.GetLastWin32Error(); - - if (msgTableInfo == IntPtr.Zero) - { - logger?.Debug( - $"No message table found. Returning 0 messages from file:\n{file}\nFindResourceEx error: {error} ({NativeMethods.FormatSystemMessage((uint)error) ?? "unknown"}). Error 1813 (ERROR_RESOURCE_TYPE_NOT_FOUND) commonly means the message table lives in a localized .mui satellite the loader could not locate, but it can also indicate a missing message table, a non-default resource id, or an unavailable language fallback."); - - continue; - } - - var msgTable = NativeMethods.LoadResource(hModule, msgTableInfo); - int loadResourceError = Marshal.GetLastWin32Error(); - - if (msgTable == IntPtr.Zero) - { - logger?.Debug( - $"LoadResource returned NULL for message table in file:\n{file}\nError: {loadResourceError} ({NativeMethods.FormatSystemMessage((uint)loadResourceError) ?? "unknown"})."); - - continue; - } - - var memTable = NativeMethods.LockResource(msgTable); - - if (memTable == IntPtr.Zero) - { - logger?.Debug($"LockResource returned NULL for message table in file:\n{file}"); - - continue; - } - - var numberOfBlocks = Marshal.ReadInt32(memTable); - var blockPtr = IntPtr.Add(memTable, 4); - var blockSize = Marshal.SizeOf(); - - for (var i = 0; i < numberOfBlocks; i++) - { - var block = Marshal.PtrToStructure(blockPtr); - var entryPtr = IntPtr.Add(memTable, block.OffsetToEntries); - - for (var id = block.LowId; id <= block.HighId; id++) - { - var length = Marshal.ReadInt16(entryPtr); - var flags = Marshal.ReadInt16(entryPtr, 2); - var textPtr = IntPtr.Add(entryPtr, 4); - - string? text = flags switch - { - 0 => Marshal.PtrToStringAnsi(textPtr), - 1 => Marshal.PtrToStringUni(textPtr), - 2 => Marshal.PtrToStringAnsi(textPtr), - // All the ESE messages are a single-byte character set - // but have flags of 2, which is not defined. So just - // treat it as ANSI I guess? - _ => "Error: Bad flags. Could not get text.", - }; - - // This is an event - messages.Add(new MessageModel - { - Text = text ?? string.Empty, - ShortId = (short)id, - ProviderName = providerName, - RawId = id - }); - - // Advance to the next id - entryPtr = IntPtr.Add(entryPtr, length); - } - - // Advance to the next block - blockPtr = IntPtr.Add(blockPtr, blockSize); - } + try { MessageTableReader.AppendMatches(memTable, size, providerName, -1, messages); } + finally { handle.Dispose(); } } return messages; } - /// - /// Loads a message-resource module using flags that honor MUI satellite resolution, with fallbacks for older - /// binaries and unresolved paths. Returns an invalid handle on failure (the caller is expected to skip). - /// - /// - /// - /// LOAD_LIBRARY_AS_DATAFILE alone does NOT trigger MUI satellite loading. Modern Windows binaries (e.g., - /// DriverStore-installed services, in-box system EXEs/DLLs) keep their RT_MESSAGETABLE resources in - /// <binary>.mui files under language subfolders rather than in the binary itself. FindResource - /// on a module loaded with only LOAD_LIBRARY_AS_DATAFILE then returns 1813 ( - /// ERROR_RESOURCE_TYPE_NOT_FOUND). Combining LOAD_LIBRARY_AS_IMAGE_RESOURCE with - /// LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE causes the loader to follow the MUI fallback chain — the same path - /// EvtFormatMessage (and Event Viewer MMC) uses. - /// - /// - private static LibraryHandle LoadMessageModule(string file, ITraceLogger? logger) + private LegacyMessageFileSource? BuildLazySource(IReadOnlyList files) { - // LoadLibraryEx does not expand environment variables. Publisher manifests typically - // store paths like %SystemRoot%\System32\foo.dll, so normalize at the loader as the - // last chokepoint before the P/Invoke. Idempotent on already-expanded paths. - file = Environment.ExpandEnvironmentVariables(file); - - // Primary attempt: MUI-aware load using the path as given. Mirrors EvtFormatMessage behavior. - const LoadLibraryFlags MuiAwareFlags = - LoadLibraryFlags.LOAD_LIBRARY_AS_IMAGE_RESOURCE | - LoadLibraryFlags.LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE; + if (files.Count == 0) { return null; } - var hModule = NativeMethods.LoadLibraryExW(file, IntPtr.Zero, MuiAwareFlags); - int error = Marshal.GetLastWin32Error(); + var walkable = new List(); + int total = 0; - if (!hModule.IsInvalid) + foreach (var file in files) { - logger?.Debug( - $"LoadLibraryEx succeeded for {file} with flags LOAD_LIBRARY_AS_IMAGE_RESOURCE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE."); + if (!MessageTableReader.TryOpen(file, _logger, out var handle, out nint memTable, out uint size)) { continue; } - return hModule; - } - - hModule.Dispose(); - - var primaryFailureMessage = - $"LoadLibraryEx failed for {file} with flags LOAD_LIBRARY_AS_IMAGE_RESOURCE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE. Error: {error} ({NativeMethods.FormatSystemMessage((uint)error) ?? "unknown"})."; - - // Legacy fallback: re-attempt the load using the leaf filename only, resolved under the - // trusted system directory. Restrict this to inputs that are already pure leaf filenames - // (no directory information of any kind). Both rooted inputs (e.g., "C:\foo.dll", - // "C:foo.dll", "\Windows\foo.dll") and unrooted inputs that include a subdirectory - // (e.g., "subdir\foo.dll") would have their directory portion stripped here and the bare - // leaf name resolved against the default DLL search order, which could load a different - // same-named binary and produce wrong message text. Path.IsPathRooted alone does NOT - // catch the "subdir\foo.dll" case, so compare against Path.GetFileName instead. - if (!string.Equals(file, Path.GetFileName(file), StringComparison.Ordinal)) - { - logger?.Debug($"{primaryFailureMessage} Skipping leaf-name fallback because the input contains directory information."); - - return LibraryHandle.Zero; - } - - var leafName = Path.GetFileName(file); - - if (string.IsNullOrEmpty(leafName)) - { - logger?.Debug($"{primaryFailureMessage} Skipping leaf-name fallback because no leaf filename could be extracted."); - - return LibraryHandle.Zero; - } - - // Constrain leaf-name resolution to the trusted system directory. Letting LoadLibraryEx - // resolve a bare leaf name through the default DLL search order would search the - // application directory first, which is a DLL planting / hijacking risk and could load - // a same-named binary with bogus message text. Historically this fallback existed for - // legacy registry values that named system binaries by leaf only (e.g., "wevtsvc.dll"); - // pinning resolution to %SystemRoot%\System32 preserves that behavior safely. - var systemPath = Path.Combine(Environment.SystemDirectory, leafName); - - if (!File.Exists(systemPath)) - { - logger?.Debug( - $"{primaryFailureMessage} Skipping leaf-name fallback because '{leafName}' does not exist under {Environment.SystemDirectory}."); - - return LibraryHandle.Zero; - } - - logger?.Debug( - $"{primaryFailureMessage} Falling back to leaf-name resolution against the system directory: {systemPath}."); - - hModule = NativeMethods.LoadLibraryExW(systemPath, IntPtr.Zero, MuiAwareFlags); - - error = Marshal.GetLastWin32Error(); - - if (!hModule.IsInvalid) - { - logger?.Debug( - $"LoadLibraryEx succeeded for {systemPath} (leaf-name fallback) with flags LOAD_LIBRARY_AS_IMAGE_RESOURCE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE."); + try + { + int count = MessageTableReader.CountEntries(memTable, size); - return hModule; + if (count > 0) + { + walkable.Add(file); + total += count; + } + } + finally { handle.Dispose(); } } - hModule.Dispose(); - - logger?.Debug( - $"LoadLibraryEx failed for {systemPath} (leaf-name fallback) with flags LOAD_LIBRARY_AS_IMAGE_RESOURCE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE. Error: {error} ({NativeMethods.FormatSystemMessage((uint)error) ?? "unknown"}). Original requested file was: {file}."); - - return LibraryHandle.Zero; + return total > 0 ? new LegacyMessageFileSource(walkable, _providerName, total, _logger) : null; } /// @@ -320,48 +163,25 @@ private ProviderDetails LoadProviderDetailsCore(HashSet? visited) var legacyProviderFiles = _registryProvider.GetMessageFilesForLegacyProvider(_providerName); - if (legacyProviderFiles.Count > 0 && - TryLoadMessages(legacyProviderFiles, out var legacyMessages) && - legacyMessages.Count > 0) + if (!TrySetLazyMessages(provider, legacyProviderFiles)) { - provider.Messages = legacyMessages; - } - else if (!string.IsNullOrEmpty(providerMetadata?.MessageFilePath)) - { - if (TryLoadMessages([providerMetadata.MessageFilePath], out var modernMessages) && modernMessages.Count > 0) + if (!string.IsNullOrEmpty(providerMetadata?.MessageFilePath) && + TrySetLazyMessages(provider, [providerMetadata.MessageFilePath])) { _logger?.Debug( $"No legacy messages loaded for provider {_providerName}. Using message file from modern provider."); - - provider.Messages = modernMessages; } else { _logger?.Debug( - $"No legacy messages loaded for provider {_providerName} and modern message file fallback produced no messages. Returning empty provider details."); + $"No message table loaded for provider {_providerName}. Returning empty provider details."); } } - else if (legacyProviderFiles.Count > 0) - { - _logger?.Debug( - $"Legacy message files for provider {_providerName} produced no messages and no modern fallback is available. Returning empty provider details."); - } - else - { - _logger?.Debug($"No message files found for provider {_providerName}. Returning empty provider details."); - } - if (!string.IsNullOrEmpty(providerMetadata?.ParameterFilePath)) + if (!string.IsNullOrEmpty(providerMetadata?.ParameterFilePath) && + !TrySetLazyParameters(provider, [providerMetadata.ParameterFilePath])) { - if (TryLoadMessages([providerMetadata.ParameterFilePath], out var parameterMessages) && parameterMessages.Count > 0) - { - provider.Parameters = parameterMessages; - } - else - { - _logger?.Debug( - $"Parameter file fallback for provider {_providerName} produced no messages."); - } + _logger?.Debug($"Parameter file for provider {_providerName} produced no messages."); } if (provider.IsEmpty) @@ -425,8 +245,11 @@ private void TryFallbackToOwningPublisher(ProviderDetails target, HashSet messageFilePaths, out List messages) + private bool TrySetLazyMessages(ProviderDetails provider, IReadOnlyList files) { - _logger?.Debug($"{nameof(TryLoadMessages)} called for files {string.Join(", ", messageFilePaths)}"); + var source = BuildLazySource(files); - try - { - messages = LoadMessagesFromFiles(messageFilePaths, _providerName, _logger); + if (source is null) { return false; } - _logger?.Debug($"Returning {messages.Count} messages for provider {_providerName}"); + provider.SetLazyMessageSource(source); - return true; - } - catch (Exception ex) - { - _logger?.Debug($"Failed to load provider data for {_providerName}.\n{ex}"); + return true; + } - messages = []; + private bool TrySetLazyParameters(ProviderDetails provider, IReadOnlyList files) + { + var source = BuildLazySource(files); - return false; - } + if (source is null) { return false; } + + provider.SetLazyParameterSource(source); + + return true; } } diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/LegacyMessageFileSource.cs b/src/EventLogExpert.Eventing/PublisherMetadata/LegacyMessageFileSource.cs new file mode 100644 index 000000000..edd925610 --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/LegacyMessageFileSource.cs @@ -0,0 +1,102 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; +using System.Collections; +using System.Collections.Concurrent; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +internal sealed class LegacyMessageFileSource : ILazyMessageSource +{ + private static readonly IReadOnlyList s_empty = []; + + private readonly Lazy> _all; + private readonly ConcurrentDictionary _byRawId = new(); + private readonly ConcurrentDictionary> _byShortId = new(); + private readonly int _count; + private readonly IReadOnlyList _files; + private readonly ITraceLogger? _logger; + private readonly string _providerName; + private IReadOnlyList? _view; + + internal LegacyMessageFileSource(IReadOnlyList files, string providerName, int count, ITraceLogger? logger) + { + _files = files; + _providerName = providerName; + _count = count; + _logger = logger; + _all = new Lazy>(MaterializeAllCore); + } + + public int Count => _count; + + public IReadOnlyList AsView() => _view ??= new LazyMessageView(_count, this); + + public MessageModel? GetByRawIdFirst(long rawId) => + _byRawId.GetOrAdd(rawId, static (id, self) => self.ExtractByRawId(id), this); + + public IReadOnlyList GetByShortId(int shortId) => + _byShortId.GetOrAdd(shortId, static (id, self) => self.ExtractByShortId(id), this); + + public IReadOnlyList MaterializeAll() => _all.Value; + + private MessageModel? ExtractByRawId(long rawId) + { + foreach (var file in _files) + { + if (!MessageTableReader.TryOpen(file, _logger, out var handle, out var memTable, out uint size)) { continue; } + + try + { + var match = MessageTableReader.FindFirstByRawId(memTable, size, rawId, _providerName); + if (match is not null) { return match; } + } + finally { handle.Dispose(); } + } + + return null; + } + + private IReadOnlyList ExtractByShortId(int shortId) + { + var result = new List(); + + foreach (var file in _files) + { + if (!MessageTableReader.TryOpen(file, _logger, out var handle, out var memTable, out uint size)) { continue; } + + try { MessageTableReader.AppendMatches(memTable, size, _providerName, shortId, result); } + finally { handle.Dispose(); } + } + + return result.Count == 0 ? s_empty : result; + } + + private IReadOnlyList MaterializeAllCore() + { + var result = new List(_count); + + foreach (var file in _files) + { + if (!MessageTableReader.TryOpen(file, _logger, out var handle, out var memTable, out uint size)) { continue; } + + try { MessageTableReader.AppendMatches(memTable, size, _providerName, -1, result); } + finally { handle.Dispose(); } + } + + return result; + } + + private sealed class LazyMessageView(int count, LegacyMessageFileSource source) : IReadOnlyList + { + public int Count => count; + + public MessageModel this[int index] => source.MaterializeAll()[index]; + + public IEnumerator GetEnumerator() => source.MaterializeAll().GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/MessageTableReader.cs b/src/EventLogExpert.Eventing/PublisherMetadata/MessageTableReader.cs new file mode 100644 index 000000000..180a01fbf --- /dev/null +++ b/src/EventLogExpert.Eventing/PublisherMetadata/MessageTableReader.cs @@ -0,0 +1,293 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Interop; +using EventLogExpert.Logging.Abstractions; +using EventLogExpert.Provider.Resolution; +using System.Runtime.InteropServices; + +namespace EventLogExpert.Eventing.PublisherMetadata; + +/// +/// Reads a provider DLL's RT_MESSAGETABLE. Every walk is bounds-checked against the resource size from +/// so a malformed offset/length can never read outside the mapped +/// resource: CountEntries returns -1 when the table is malformed anywhere (callers treat it as no table), while +/// per-id extraction stops at the first malformed region and returns only the matches from the well-formed prefix, +/// never an access violation. Modules are loaded MUI-aware and freed immediately - no handle is held across calls. +/// +internal static class MessageTableReader +{ + /// + /// Appends entries to in walk order (file -> block -> id). When + /// is -1 every entry is appended; otherwise only entries whose unsigned low-16 + /// ShortId equals the filter (matching the eager store's (ushort)ShortId keying). + /// + internal static void AppendMatches(nint memTable, uint size, string providerName, int shortIdFilter, List into) + { + if (!InBounds(0, 4, size)) { return; } + + int numberOfBlocks = Marshal.ReadInt32(memTable); + + if (numberOfBlocks < 0) { return; } + + int blockOffset = 4; + + for (int block = 0; block < numberOfBlocks; block++) + { + if (!InBounds(blockOffset, 12, size)) { return; } + + int lowId = Marshal.ReadInt32(memTable, blockOffset); + int highId = Marshal.ReadInt32(memTable, blockOffset + 4); + int entryOffset = Marshal.ReadInt32(memTable, blockOffset + 8); + + if (lowId > highId) { return; } + + for (long id = lowId; id <= highId; id++) + { + if (!InBounds(entryOffset, 4, size)) { return; } + + short length = Marshal.ReadInt16(memTable, entryOffset); + short flags = Marshal.ReadInt16(memTable, entryOffset + 2); + + if (length < 4 || !InBounds(entryOffset, length, size)) { return; } + + if (shortIdFilter < 0 || (ushort)(short)id == shortIdFilter) + { + into.Add(new MessageModel + { + Text = ReadText(memTable + entryOffset + 4, length - 4, flags), + ShortId = (short)id, + ProviderName = providerName, + RawId = id + }); + } + + entryOffset += length; + } + + blockOffset += 12; + } + } + + /// Counts entries with a fully bounds-checked structural walk (no string materialization). -1 if malformed. + internal static int CountEntries(nint memTable, uint size) + { + if (!InBounds(0, 4, size)) { return -1; } + + int numberOfBlocks = Marshal.ReadInt32(memTable); + + if (numberOfBlocks < 0) { return -1; } + + int count = 0; + int blockOffset = 4; + + for (int block = 0; block < numberOfBlocks; block++) + { + if (!InBounds(blockOffset, 12, size)) { return -1; } + + int lowId = Marshal.ReadInt32(memTable, blockOffset); + int highId = Marshal.ReadInt32(memTable, blockOffset + 4); + int entryOffset = Marshal.ReadInt32(memTable, blockOffset + 8); + + if (lowId > highId) { return -1; } + + for (long id = lowId; id <= highId; id++) + { + if (!InBounds(entryOffset, 4, size)) { return -1; } + + short length = Marshal.ReadInt16(memTable, entryOffset); + + if (length < 4 || !InBounds(entryOffset, length, size)) { return -1; } + + count++; + entryOffset += length; + } + + blockOffset += 12; + } + + return count; + } + + /// Returns the first entry whose RawId equals (first-wins), or null. + internal static MessageModel? FindFirstByRawId(nint memTable, uint size, long rawId, string providerName) + { + if (!InBounds(0, 4, size)) { return null; } + + int numberOfBlocks = Marshal.ReadInt32(memTable); + + if (numberOfBlocks < 0) { return null; } + + int blockOffset = 4; + + for (int block = 0; block < numberOfBlocks; block++) + { + if (!InBounds(blockOffset, 12, size)) { return null; } + + int lowId = Marshal.ReadInt32(memTable, blockOffset); + int highId = Marshal.ReadInt32(memTable, blockOffset + 4); + int entryOffset = Marshal.ReadInt32(memTable, blockOffset + 8); + + if (lowId > highId) { return null; } + + for (long id = lowId; id <= highId; id++) + { + if (!InBounds(entryOffset, 4, size)) { return null; } + + short length = Marshal.ReadInt16(memTable, entryOffset); + short flags = Marshal.ReadInt16(memTable, entryOffset + 2); + + if (length < 4 || !InBounds(entryOffset, length, size)) { return null; } + + if (id == rawId) + { + return new MessageModel + { + Text = ReadText(memTable + entryOffset + 4, length - 4, flags), + ShortId = (short)id, + ProviderName = providerName, + RawId = id + }; + } + + entryOffset += length; + } + + blockOffset += 12; + } + + return null; + } + + internal static bool TryOpen(string file, ITraceLogger? logger, out LibraryHandle handle, out nint memTable, out uint size) + { + memTable = nint.Zero; + size = 0; + handle = LoadMessageModule(file, logger); + + if (handle.IsInvalid) + { + handle.Dispose(); + + return false; + } + + nint info = NativeMethods.FindResourceExA(handle, NativeMethods.RT_MESSAGETABLE, 1); + + if (info == nint.Zero) + { + handle.Dispose(); + + return false; + } + + size = NativeMethods.SizeofResource(handle, info); + nint resource = NativeMethods.LoadResource(handle, info); + + if (resource == nint.Zero || size == 0) + { + handle.Dispose(); + + return false; + } + + memTable = NativeMethods.LockResource(resource); + + if (memTable != nint.Zero) { return true; } + + handle.Dispose(); + + return false; + } + + private static bool InBounds(int offset, int length, uint size) => + offset >= 0 && length >= 0 && (long)offset + length <= size; + + private static LibraryHandle LoadMessageModule(string file, ITraceLogger? logger) + { + file = Environment.ExpandEnvironmentVariables(file); + + const LoadLibraryFlags MuiAwareFlags = + LoadLibraryFlags.LOAD_LIBRARY_AS_IMAGE_RESOURCE | + LoadLibraryFlags.LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE; + + var module = NativeMethods.LoadLibraryExW(file, nint.Zero, MuiAwareFlags); + int error = Marshal.GetLastWin32Error(); + + if (!module.IsInvalid) { return module; } + + module.Dispose(); + + string primaryFailure = + $"LoadLibraryEx failed for {file} with flags LOAD_LIBRARY_AS_IMAGE_RESOURCE | " + + $"LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE. Error: {error} ({NativeMethods.FormatSystemMessage((uint)error) ?? "unknown"})."; + + // Legacy fallback: re-attempt with the leaf filename resolved under the trusted system directory, but only for + // pure leaf inputs (no directory information) so a "subdir\foo.dll" can never be hijacked to a system binary. + if (!string.Equals(file, Path.GetFileName(file), StringComparison.Ordinal)) + { + logger?.Debug($"{primaryFailure} Skipping leaf-name fallback because the input contains directory information."); + + return LibraryHandle.Zero; + } + + string leafName = Path.GetFileName(file); + + if (string.IsNullOrEmpty(leafName)) + { + logger?.Debug($"{primaryFailure} Skipping leaf-name fallback because no leaf filename could be extracted."); + + return LibraryHandle.Zero; + } + + string systemPath = Path.Combine(Environment.SystemDirectory, leafName); + + if (!File.Exists(systemPath)) + { + logger?.Debug($"{primaryFailure} Skipping leaf-name fallback because '{leafName}' does not exist under {Environment.SystemDirectory}."); + + return LibraryHandle.Zero; + } + + logger?.Debug($"{primaryFailure} Falling back to leaf-name resolution against the system directory: {systemPath}."); + + module = NativeMethods.LoadLibraryExW(systemPath, nint.Zero, MuiAwareFlags); + error = Marshal.GetLastWin32Error(); + + if (!module.IsInvalid) { return module; } + + module.Dispose(); + + logger?.Debug( + $"LoadLibraryEx failed for {systemPath} (leaf-name fallback) with flags LOAD_LIBRARY_AS_IMAGE_RESOURCE | " + + $"LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE. Error: {error} ({NativeMethods.FormatSystemMessage((uint)error) ?? "unknown"}). " + + $"Original requested file was: {file}."); + + return LibraryHandle.Zero; + } + + // Reads the entry text up to the first null, bounded by the entry length. flags: 1 = Unicode, 0 and 2 = single-byte + // (2 is undefined but ESE message tables use it for ANSI); any other value keeps the legacy parser's sentinel + // instead of decoding arbitrary bytes. + private static string ReadText(nint textPtr, int maxBytes, short flags) + { + if (flags is not (0 or 1 or 2)) { return "Error: Bad flags. Could not get text."; } + + if (maxBytes <= 0) { return string.Empty; } + + if (flags == 1) + { + int chars = 0; + int maxChars = maxBytes / 2; + while (chars < maxChars && Marshal.ReadInt16(textPtr, chars * 2) != 0) { chars++; } + + return Marshal.PtrToStringUni(textPtr, chars); + } + + int bytes = 0; + + while (bytes < maxBytes && Marshal.ReadByte(textPtr, bytes) != 0) { bytes++; } + + return Marshal.PtrToStringAnsi(textPtr, bytes); + } +} diff --git a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs index 7043b5d42..395ffe7fb 100644 --- a/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs +++ b/src/EventLogExpert.Eventing/PublisherMetadata/ProviderMetadata.cs @@ -465,9 +465,9 @@ private EvtHandle GetPublisherMetadataPropertyHandle(EvtPublisherMetadataPropert var variant = Marshal.PtrToStructure(buffer); - return variant.Handle == IntPtr.Zero ? + return variant.EvtHandleVal == IntPtr.Zero ? EvtHandle.Zero : - new EvtHandle(variant.Handle); + new EvtHandle(variant.EvtHandleVal); } finally { diff --git a/src/EventLogExpert.Eventing/Readers/EventLogReader.cs b/src/EventLogExpert.Eventing/Readers/EventLogReader.cs index 333f7b509..72a64f8f2 100644 --- a/src/EventLogExpert.Eventing/Readers/EventLogReader.cs +++ b/src/EventLogExpert.Eventing/Readers/EventLogReader.cs @@ -8,13 +8,21 @@ namespace EventLogExpert.Eventing.Readers; -public sealed class EventLogReader(string path, LogPathType pathType, bool renderXml = false) : IDisposable +public sealed class EventLogReader(string path, LogPathType pathType, bool renderXml = false, bool reverseDirection = false) : IEventLogReader { + private const int EvtQueryReverseDirection = 0x200; + private readonly Lock _eventLock = new(); - private readonly EvtHandle _handle = - NativeMethods.EvtQuery(EventLogSession.GlobalSession.Handle, path, null, pathType); + private readonly EvtHandle _handle = reverseDirection + ? NativeMethods.EvtQueryWithFlags(EventLogSession.GlobalSession.Handle, path, null, + (int)pathType | EvtQueryReverseDirection) + : NativeMethods.EvtQuery(EventLogSession.GlobalSession.Handle, path, null, pathType); + + private readonly int _openError = Marshal.GetLastWin32Error(); private int _disposed; + private bool _newestCaptured; + private string? _newestReverseBookmark; /// /// when the underlying EvtQuery handle was opened successfully. When @@ -32,6 +40,23 @@ public sealed class EventLogReader(string path, LogPathType pathType, bool rende /// public int? LastErrorCode { get; private set; } + /// + /// The bookmark of the NEWEST event this reader has returned, irrespective of read direction. It is the correct + /// resume point for a live-tail watcher, because the genuinely new events are the ones created after the newest one + /// already loaded. For a forward (oldest-first) read this is the most recently returned event and aliases + /// ; for a reverse (newest-first) read it is the FIRST event returned and is captured once. + /// Unlike , which is always the last event ENUMERATED (and therefore the OLDEST event under + /// a reverse read), this never points at the wrong end of the log. + /// + public string? NewestBookmark => reverseDirection ? _newestReverseBookmark : LastBookmark; + + /// + /// The Win32 error from a failed EvtQuery open, or when the log opened ( + /// is ). Captured once at construction, so unlike the per-read + /// it is stable and reflects the open failure rather than a later read. + /// + public int? OpenErrorCode => IsValid ? null : _openError; + public void Dispose() { // Use Interlocked.CompareExchange for atomic check-and-set. @@ -52,7 +77,10 @@ public void Dispose() // With a maximum event size of 64 KB, the maximum batchSize that won't exceed the maximum buffer // size is 30 (32 minus some overhead; refer to MS-EVEN6 for details). // Windows 11 and later will stop filling out the buffer when the maximum size is reached, regardless - // of whether the requested batchSize was reached (but it will not exceed the requested count). + // of whether the requested batchSize was reached (but it will not exceed the requested count). The 30 + // ceiling is a pre-Windows 11 constraint; on supported Windows 11+ deployments a larger batchSize is + // safe and faster (EvtNext just returns fewer when the 2 MB buffer fills first), so the log-load path + // requests more. public bool TryGetEvents(out EventRecord[] events, int batchSize = 30) { var buffer = ArrayPool.Shared.Rent(batchSize); @@ -72,12 +100,29 @@ public bool TryGetEvents(out EventRecord[] events, int batchSize = 30) LastErrorCode = null; - using (_eventLock.EnterScope()) + try { - LastBookmark = CreateBookmark(new EvtHandle(buffer[count - 1], false)); + using (_eventLock.EnterScope()) + { + LastBookmark = CreateBookmark(new EvtHandle(buffer[count - 1], false)); + + if (reverseDirection && !_newestCaptured) + { + _newestReverseBookmark = CreateBookmark(new EvtHandle(buffer[0], false)); + _newestCaptured = true; + } + } + + events = new EventRecord[count]; } + catch + { + // Bookmark capture (or the result allocation) threw before the render loop disposed the + // batch handles; close them here so a failed read never leaks up to batchSize native event handles. + for (int i = 0; i < count; i++) { new EvtHandle(buffer[i]).Dispose(); } - events = new EventRecord[count]; + throw; + } for (int i = 0; i < count; i++) { diff --git a/src/EventLogExpert.Eventing/Readers/IEventLogReader.cs b/src/EventLogExpert.Eventing/Readers/IEventLogReader.cs new file mode 100644 index 000000000..b5565658b --- /dev/null +++ b/src/EventLogExpert.Eventing/Readers/IEventLogReader.cs @@ -0,0 +1,43 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Eventing.Readers; + +/// +/// Reads event-log events in batches. Abstracts so the load pipeline can be driven +/// with a substitute reader under test. Only the members the pipeline consumes are exposed. +/// +public interface IEventLogReader : IDisposable +{ + /// + /// True when the underlying log opened successfully. False means the source could not be opened (the log does not + /// exist, access was denied, etc.); the load pipeline should surface a failure rather than an empty log. + /// + bool IsValid { get; } + + /// + /// The Win32 error from the most recent that returned false, or + /// when that false meant a clean end-of-results. A non-null value once the read loop ends + /// signals a failed read, not an empty log. + /// + int? LastErrorCode { get; } + + /// + /// The bookmark of the NEWEST event returned so far, irrespective of read direction. This is the correct resume + /// point for a live-tail watcher. until the first event is returned. + /// + string? NewestBookmark { get; } + + /// + /// The Win32 error from a failed open, or when . Captured once at + /// open so it is stable (distinct from the per-read ); use it to report why a log could + /// not be opened. + /// + int? OpenErrorCode { get; } + + /// + /// Reads the next batch of up to events. Returns when + /// there are no more events (or the source could not be opened), leaving empty. + /// + bool TryGetEvents(out EventRecord[] events, int batchSize = 30); +} diff --git a/src/EventLogExpert.ExplorerExtensionNative/EventLogExpert.ExplorerExtensionNative.vcxproj b/src/EventLogExpert.ExplorerExtensionNative/EventLogExpert.ExplorerExtensionNative.vcxproj index b9517012b..9d45251b4 100644 --- a/src/EventLogExpert.ExplorerExtensionNative/EventLogExpert.ExplorerExtensionNative.vcxproj +++ b/src/EventLogExpert.ExplorerExtensionNative/EventLogExpert.ExplorerExtensionNative.vcxproj @@ -15,12 +15,16 @@ by the release pipeline) fail with `error MSB8036: The Windows SDK version ... was not found` because the bare WindowsTargetPlatformVersion '10.0' below resolves to "latest installed" and nothing is installed. The Exists() guards keep the build working when the packages are not - restored — it then falls back to the latest machine-installed SDK via the bare '10.0'. The - x64 package supplies only the x64 import libs (it carries no .targets). Versions are pinned in + restored, it then falls back to the latest machine-installed SDK via the bare '10.0'. The + per-architecture .x64 / .arm64 packages each supply only their own import libs (they carry no + .targets); the arm64 import below is gated to ARM64 builds. Versions are pinned in packages.config; bump in lockstep there. See docs/Explorer-Context-Menu.md. --> + + @@ -31,6 +35,14 @@ Release x64 + + Debug + ARM64 + + + Release + ARM64 + @@ -69,6 +81,23 @@ load briefly from dllhost.exe and aren't a typical Spectre-sensitive surface. --> + + + DynamicLibrary + true + $(DefaultPlatformToolset) + Unicode + + + + DynamicLibrary + false + $(DefaultPlatformToolset) + true + Unicode + + @@ -78,6 +107,12 @@ + + + + + + @@ -87,6 +122,12 @@ $(IntDir)Generated Files;$(IncludePath) + + $(IntDir)Generated Files;$(IncludePath) + + + $(IntDir)Generated Files;$(IncludePath) + @@ -131,6 +172,48 @@ + + + Level4 + true + _DEBUG;EVENTLOGEXPERTEXPLOREREXTENSION_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + Use + pch.h + stdcpp20 + + + Windows + true + false + Source.def + + + + + + Level4 + true + true + true + NDEBUG;EVENTLOGEXPERTEXPLOREREXTENSION_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + Use + pch.h + stdcpp20 + Guard + MultiThreaded + + + Windows + true + true + true + false + Source.def + + + diff --git a/src/EventLogExpert.ExplorerExtensionNative/packages.config b/src/EventLogExpert.ExplorerExtensionNative/packages.config index bfdf2a6e6..dbe931e25 100644 --- a/src/EventLogExpert.ExplorerExtensionNative/packages.config +++ b/src/EventLogExpert.ExplorerExtensionNative/packages.config @@ -6,8 +6,10 @@ from the restore output instead of a machine-installed SDK. Required for CI containers that ship the VC++ toolset but no Windows SDK (e.g. the OneBranch vse2022 image). The base package provides headers/winmd + sets WindowsSdkDir/WindowsTargetPlatformVersion; the .x64 - package provides the x64 import libraries. Both are required (no transitive dependency). - See docs/Explorer-Context-Menu.md. --> + and .arm64 packages provide the per-architecture import libraries (an x64 build uses .x64, + an arm64 build uses .arm64). The base + the matching per-arch package are required (no + transitive dependency). See docs/Explorer-Context-Menu.md. --> + diff --git a/src/EventLogExpert.Provider/Resolution/CompactMessageStore.cs b/src/EventLogExpert.Provider/Resolution/CompactMessageStore.cs new file mode 100644 index 000000000..98feb0ac4 --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/CompactMessageStore.cs @@ -0,0 +1,158 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using System.Collections; +using System.Collections.Concurrent; + +namespace EventLogExpert.Provider.Resolution; + +internal sealed class CompactMessageStore : ILazyMessageSource +{ + private static readonly IReadOnlyList s_empty = []; + + private readonly ConcurrentDictionary> _byShortIdCache = new(); + private readonly MessageEntry[] _entries; + private readonly Lazy> _firstIndexByRawId; + private readonly string _providerName; + private readonly IReadOnlyDictionary? _rareByIndex; + private readonly ConcurrentDictionary _rawIdCache = new(); + private readonly int[] _sortedByShortId; + + private CompactMessageStore( + MessageEntry[] entries, + int[] sortedByShortId, + IReadOnlyDictionary? rareByIndex, + string providerName) + { + _entries = entries; + _sortedByShortId = sortedByShortId; + _rareByIndex = rareByIndex; + _providerName = providerName; + _firstIndexByRawId = new Lazy>(BuildRawIdLookup); + } + + public int Count => _entries.Length; + + public static CompactMessageStore Build(IReadOnlyList messages) + { + int count = messages.Count; + var entries = new MessageEntry[count]; + Dictionary? rare = null; + string providerName = count > 0 ? messages[0].ProviderName : string.Empty; + + for (int i = 0; i < count; i++) + { + var m = messages[i]; + entries[i] = new MessageEntry(m.ShortId, m.RawId, m.Text); + + // An entry is "rare" when materializing it from (ShortId, RawId, Text) + the shared provider name would + // not reproduce the original byte-for-byte: any non-null LogLink/Tag/Template, or a differing ProviderName. + if (m.LogLink is not null || + m.Tag is not null || + m.Template is not null || + !string.Equals(m.ProviderName, providerName, StringComparison.Ordinal)) + { + (rare ??= [])[i] = m; + } + } + + // Stable sort of indices by unsigned ShortId; ties keep ascending original ordinal so the per-ShortId run + // preserves insertion order (load-bearing for legacy-message disambiguation). + var sorted = new int[count]; + for (int i = 0; i < count; i++) { sorted[i] = i; } + Array.Sort(sorted, (a, b) => + { + int ka = (ushort)entries[a].ShortId, kb = (ushort)entries[b].ShortId; + return ka != kb ? ka.CompareTo(kb) : a.CompareTo(b); + }); + + return new CompactMessageStore(entries, sorted, rare, providerName); + } + + public IReadOnlyList AsView() => new MessageView(this); + + public MessageModel? GetByRawIdFirst(long rawId) => + _rawIdCache.GetOrAdd(rawId, static (id, store) => store.MaterializeByRawId(id), this); + + public IReadOnlyList GetByShortId(int shortId) => + // The arg is NOT cast to ushort: a >65535 arg must not wrap and false-match (matches the prior + // Dictionary keyed by (ushort)ShortId and looked up by the raw int id). + _byShortIdCache.GetOrAdd(shortId, static (id, store) => store.MaterializeByShortId(id), this); + + public IReadOnlyList MaterializeAll() => [.. new MessageView(this)]; + + private Dictionary BuildRawIdLookup() + { + var lookup = new Dictionary(_entries.Length); + + for (int i = 0; i < _entries.Length; i++) { lookup.TryAdd(_entries[i].RawId, i); } + + return lookup; + } + + private int LowerBound(int key) + { + int lo = 0, hi = _sortedByShortId.Length; + + while (lo < hi) + { + int mid = lo + ((hi - lo) >> 1); + + if ((ushort)_entries[_sortedByShortId[mid]].ShortId < key) { lo = mid + 1; } + else { hi = mid; } + } + + return lo; + } + + private MessageModel Materialize(int index) + { + if (_rareByIndex is not null && _rareByIndex.TryGetValue(index, out var full)) { return full; } + + var entry = _entries[index]; + + return new MessageModel + { + Text = entry.Text, + ShortId = entry.ShortId, + ProviderName = _providerName, + RawId = entry.RawId + }; + } + + private MessageModel? MaterializeByRawId(long rawId) => + _firstIndexByRawId.Value.TryGetValue(rawId, out int index) ? Materialize(index) : null; + + private IReadOnlyList MaterializeByShortId(int key) + { + // The sorted index groups all entries with a given unsigned ShortId contiguously; binary-search the run. + int lo = LowerBound(key); + + if (lo == _sortedByShortId.Length || (ushort)_entries[_sortedByShortId[lo]].ShortId != key) { return s_empty; } + + var result = new List(); + + for (int i = lo; i < _sortedByShortId.Length && (ushort)_entries[_sortedByShortId[i]].ShortId == key; i++) + { + result.Add(Materialize(_sortedByShortId[i])); + } + + return result; + } + + private sealed class MessageView(CompactMessageStore store) : IReadOnlyList + { + public int Count => store._entries.Length; + + public MessageModel this[int index] => store.Materialize(index); + + public IEnumerator GetEnumerator() + { + for (int i = 0; i < store._entries.Length; i++) { yield return store.Materialize(i); } + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + + internal readonly record struct MessageEntry(short ShortId, long RawId, string Text); +} diff --git a/src/EventLogExpert.Provider/Resolution/ILazyMessageSource.cs b/src/EventLogExpert.Provider/Resolution/ILazyMessageSource.cs new file mode 100644 index 000000000..7077f2ba5 --- /dev/null +++ b/src/EventLogExpert.Provider/Resolution/ILazyMessageSource.cs @@ -0,0 +1,17 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Provider.Resolution; + +public interface ILazyMessageSource +{ + int Count { get; } + + IReadOnlyList AsView(); + + MessageModel? GetByRawIdFirst(long rawId); + + IReadOnlyList GetByShortId(int shortId); + + IReadOnlyList MaterializeAll(); +} diff --git a/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs b/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs index 9a23a2397..51f68d9d6 100644 --- a/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs +++ b/src/EventLogExpert.Provider/Resolution/ProviderDetails.cs @@ -7,10 +7,10 @@ public sealed class ProviderDetails { private IReadOnlyList _events = []; private Dictionary>? _eventsByIdLookup; - private IReadOnlyList _messages = []; - private Dictionary>? _messagesByShortIdLookup; - private IReadOnlyList _parameters = []; - private Dictionary? _parametersByRawIdLookup; + private ILazyMessageSource? _messageStore; + private IReadOnlyList? _messagesView; + private ILazyMessageSource? _parameterStore; + private IReadOnlyList? _parametersView; /// Events and related items from modern provider public IReadOnlyList Events @@ -25,39 +25,41 @@ public IReadOnlyList Events public bool IsEmpty => Events.Count == 0 && - Messages.Count == 0 && + (_messageStore?.Count ?? 0) == 0 && Keywords.Count == 0 && Opcodes.Count == 0 && Tasks.Count == 0 && - Parameters.Count == 0 && + (_parameterStore?.Count ?? 0) == 0 && ResolvedFromOwningPublisher is null; public IDictionary Keywords { get; set; } = new Dictionary(); - /// Messages from legacy provider public IReadOnlyList Messages { - get => _messages; + get => _messagesView ?? []; set { - _messages = value; - _messagesByShortIdLookup = null; + _messageStore = CompactMessageStore.Build(value); + _messagesView = _messageStore.AsView(); } } + public ILazyMessageSource? MessageSource => _messageStore; + public IDictionary Opcodes { get; set; } = new Dictionary(); - /// Parameter strings from legacy provider public IReadOnlyList Parameters { - get => _parameters; + get => _parametersView ?? []; set { - _parameters = value; - _parametersByRawIdLookup = null; + _parameterStore = CompactMessageStore.Build(value); + _parametersView = _parameterStore.AsView(); } } + public ILazyMessageSource? ParameterSource => _parameterStore; + public string ProviderName { get; set; } = string.Empty; public string? ResolvedFromOwningPublisher { get; set; } @@ -73,25 +75,28 @@ public IReadOnlyList GetEventsById(long id) } /// - /// Gets messages matching the given ShortId using a pre-built lookup dictionary. The lookup uses int keys derived - /// from unsigned reinterpretation of ShortId, matching the implicit ushort-to-int promotion used by callers. + /// Gets messages matching the given ShortId (compared as unsigned, matching the implicit ushort-to-int promotion + /// used by callers). Materialized on demand from the compact store and cached. /// - public IReadOnlyList GetMessagesByShortId(int shortId) - { - _messagesByShortIdLookup ??= BuildMessagesByShortIdLookup(); - - return _messagesByShortIdLookup.TryGetValue(shortId, out var list) ? list : []; - } + public IReadOnlyList GetMessagesByShortId(int shortId) => + _messageStore?.GetByShortId(shortId) ?? []; /// /// Gets the first parameter message with the given RawId, or null when none matches. Duplicate RawIds resolve - /// first-wins. + /// first-wins. Materialized on demand from the compact store and cached. /// - public MessageModel? GetParameterByRawId(long rawId) + public MessageModel? GetParameterByRawId(long rawId) => _parameterStore?.GetByRawIdFirst(rawId); + + public void SetLazyMessageSource(ILazyMessageSource source) { - _parametersByRawIdLookup ??= BuildParametersByRawIdLookup(); + _messageStore = source; + _messagesView = source.AsView(); + } - return _parametersByRawIdLookup.GetValueOrDefault(rawId); + public void SetLazyParameterSource(ILazyMessageSource source) + { + _parameterStore = source; + _parametersView = source.AsView(); } private Dictionary> BuildEventsByIdLookup() @@ -111,38 +116,4 @@ private Dictionary> BuildEventsByIdLookup() return lookup; } - - private Dictionary> BuildMessagesByShortIdLookup() - { - var lookup = new Dictionary>(); - - foreach (var m in _messages) - { - // Reinterpret ShortId as unsigned to match ushort-to-int promotion - // used by callers (EventRecord.Id and Task are ushort). - int key = (ushort)m.ShortId; - - if (!lookup.TryGetValue(key, out var list)) - { - list = []; - lookup[key] = list; - } - - list.Add(m); - } - - return lookup; - } - - private Dictionary BuildParametersByRawIdLookup() - { - var lookup = new Dictionary(_parameters.Count); - - foreach (var p in _parameters) - { - lookup.TryAdd(p.RawId, p); - } - - return lookup; - } } diff --git a/src/EventLogExpert.Runtime/Concurrency/PrioritySemaphore.cs b/src/EventLogExpert.Runtime/Concurrency/PrioritySemaphore.cs new file mode 100644 index 000000000..0e162f321 --- /dev/null +++ b/src/EventLogExpert.Runtime/Concurrency/PrioritySemaphore.cs @@ -0,0 +1,82 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Concurrency; + +/// +/// An async semaphore with two priority classes. On release a +/// waiter is woken before any waiter, so a newly-opened log's first screenful +/// preempts in-flight bulk resolution instead of queuing behind it. A single static instance shares permits across all +/// loads; the gate is work-conserving (a lone load uses every permit for both phases). +/// +internal sealed class PrioritySemaphore +{ + private readonly Queue _high = new(); + private readonly Lock _lock = new(); + private readonly Queue _low = new(); + private int _available; + + internal PrioritySemaphore(int permits) => _available = permits; + + /// Available permits. Test-only observability for deterministic permit-conservation assertions. + internal int CurrentCount { get { lock (_lock) { return _available; } } } + + /// Queued (high, low) waiter counts. Test-only; canceled waiters linger until a release skips them. + internal (int High, int Low) WaiterCounts { get { lock (_lock) { return (_high.Count, _low.Count); } } } + + internal void Release() + { + lock (_lock) + { + // Wake the next live waiter, high priority first; canceled waiters are skipped (TrySetResult false) + // and dropped from the queue, so a released permit is never lost to a waiter that will never run. + while (_high.Count > 0) + { + if (_high.Dequeue().TrySetResult()) { return; } + } + + while (_low.Count > 0) + { + if (_low.Dequeue().TrySetResult()) { return; } + } + + _available++; + } + } + + internal Task WaitAsync(ResolutionPriority priority, CancellationToken token) + { + if (token.IsCancellationRequested) { return Task.FromCanceled(token); } + + lock (_lock) + { + if (_available > 0) + { + _available--; + + return Task.CompletedTask; + } + + var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + (priority == ResolutionPriority.FirstScreenful ? _high : _low).Enqueue(waiter); + + if (!token.CanBeCanceled) { return waiter.Task; } + + // On cancellation, complete the waiter as canceled. Release skips already-canceled waiters + // (TrySetResult returns false), so the permit is never lost to a waiter that will never run. + var registration = token.Register(static (state, cancellationToken) => + ((TaskCompletionSource)state!).TrySetCanceled(cancellationToken), + waiter); + + // Dispose the registration once the wait settles (granted or canceled) so it isn't retained. + waiter.Task.ContinueWith( + static (_, state) => ((CancellationTokenRegistration)state!).Dispose(), + registration, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + return waiter.Task; + } + } +} diff --git a/src/EventLogExpert.Runtime/Concurrency/ResolutionPriority.cs b/src/EventLogExpert.Runtime/Concurrency/ResolutionPriority.cs new file mode 100644 index 000000000..b3533e30e --- /dev/null +++ b/src/EventLogExpert.Runtime/Concurrency/ResolutionPriority.cs @@ -0,0 +1,14 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +namespace EventLogExpert.Runtime.Concurrency; + +/// Priority class for a waiter. +internal enum ResolutionPriority +{ + /// A load's first screenful of newest events; preempts in-flight work. + FirstScreenful, + + /// Background bulk resolution; yields to waiters. + Bulk +} diff --git a/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs b/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs index c35221023..ef85dab00 100644 --- a/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs +++ b/src/EventLogExpert.Runtime/DatabaseTools/Elevation/ElevatedDatabaseToolsRunner.cs @@ -248,7 +248,7 @@ private void HandleCallerCancellation(Stream pipeStream, SemaphoreSlim writeLock var graceCts = new CancellationTokenSource(); killState.SetGraceTimer(graceCts); - _ = Task.Run(async () => + var killTask = Task.Run(async () => { try { @@ -276,6 +276,8 @@ private void HandleCallerCancellation(Stream pipeStream, SemaphoreSlim writeLock _traceLogger.Warning($"{ElevatedHelperTag} kill-timer task threw {ex.GetType().Name}: {ex.Message}"); } }); + + killState.SetKillTask(killTask); } private void MirrorMessageToDebugLog(DatabaseToolsIpcMessage message) @@ -493,6 +495,13 @@ private async Task RunAsync( // Helper sent a terminal message (or pipe closed). Cancel the kill-timer so it doesn't fire on a clean finish. killState.CancelGraceTimer(); + // Join the kill-timer so its disposition write happens-before the TranslateOutcome read. + try { await killState.KillTaskOrCompleted.WaitAsync(_exitGrace); } + catch (TimeoutException) + { + _traceLogger.Warning($"{ElevatedHelperTag} kill-timer did not settle within {_exitGrace.TotalSeconds:N0}s; proceeding with current disposition."); + } + // 7) Wait for helper to exit (bounded by _exitGrace, then force-kill if it lingers and is killable). int exitCode; try @@ -684,6 +693,7 @@ private sealed class KillState private int _cancelRequested; private int _disposition; private CancellationTokenSource? _graceTimerCts; + private Task? _killTask; public KillDisposition Disposition => (KillDisposition)Volatile.Read(ref _disposition); @@ -711,5 +721,9 @@ public void MarkKillFailed() => Interlocked.CompareExchange( public void MarkKillSucceeded() => Interlocked.Exchange(ref _disposition, (int)KillDisposition.Succeeded); public void SetGraceTimer(CancellationTokenSource cts) => Volatile.Write(ref _graceTimerCts, cts); + + public void SetKillTask(Task task) => Volatile.Write(ref _killTask, task); + + public Task KillTaskOrCompleted => Volatile.Read(ref _killTask) ?? Task.CompletedTask; } } diff --git a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs index 7ed4d3b31..81db92888 100644 --- a/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs +++ b/src/EventLogExpert.Runtime/DependencyInjection/RuntimeServiceCollectionExtensions.cs @@ -159,6 +159,7 @@ public IServiceCollection AddEventLogRuntime() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // UI capabilities. services.AddSingleton(); diff --git a/src/EventLogExpert.Runtime/EventLog/EventLogReaderFactory.cs b/src/EventLogExpert.Runtime/EventLog/EventLogReaderFactory.cs new file mode 100644 index 000000000..def5a60da --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/EventLogReaderFactory.cs @@ -0,0 +1,13 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Readers; + +namespace EventLogExpert.Runtime.EventLog; + +internal sealed class EventLogReaderFactory : IEventLogReaderFactory +{ + public IEventLogReader CreateReader(string path, LogPathType pathType, bool renderXml = false, bool reverseDirection = false) => + new EventLogReader(path, pathType, renderXml, reverseDirection); +} diff --git a/src/EventLogExpert.Runtime/EventLog/IEventLogReaderFactory.cs b/src/EventLogExpert.Runtime/EventLog/IEventLogReaderFactory.cs new file mode 100644 index 000000000..1a34bce65 --- /dev/null +++ b/src/EventLogExpert.Runtime/EventLog/IEventLogReaderFactory.cs @@ -0,0 +1,12 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Readers; + +namespace EventLogExpert.Runtime.EventLog; + +internal interface IEventLogReaderFactory +{ + IEventLogReader CreateReader(string path, LogPathType pathType, bool renderXml = false, bool reverseDirection = false); +} diff --git a/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs b/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs index 7f21a9c11..459409428 100644 --- a/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs +++ b/src/EventLogExpert.Runtime/EventLog/OpenLogEffects.cs @@ -8,13 +8,16 @@ using EventLogExpert.Eventing.Resolvers; using EventLogExpert.Logging.Abstractions; using EventLogExpert.Runtime.Banner; +using EventLogExpert.Runtime.Concurrency; using EventLogExpert.Runtime.Database; using EventLogExpert.Runtime.LogTable; using EventLogExpert.Runtime.StatusBar; using Fluxor; using Microsoft.Extensions.DependencyInjection; using System.Collections.Concurrent; +using System.ComponentModel; using System.Diagnostics; +using System.Runtime.InteropServices; using System.Security; using System.Threading.Channels; using IDispatcher = Fluxor.IDispatcher; @@ -32,10 +35,18 @@ internal sealed class OpenLogEffects( ICriticalErrorService criticalErrorService, LogCloseCoordinator closeCoordinator, EventLogConcurrencyState concurrencyState, - PartialLoadCoordinator coordinator) + PartialLoadCoordinator coordinator, + IEventLogReaderFactory readerFactory) { + // A screenful of newest events: the eager first paint dispatches once this many are resolved so the newest rows + // render in ~1s instead of waiting for the 3-second partial timer. + private const int EagerFirstPaintThreshold = 200; + + // EvtNext batch: benchmarked Win11 throughput sweet spot; 512 regresses. + private const int ReadBatchSize = 256; + private static readonly int s_maxGlobalConcurrency = Math.Max(1, Environment.ProcessorCount - 1); - private static readonly SemaphoreSlim s_resolutionThrottle = new(s_maxGlobalConcurrency, s_maxGlobalConcurrency); + private static readonly PrioritySemaphore s_resolutionGate = new(s_maxGlobalConcurrency); private readonly LogCloseCoordinator _closeCoordinator = closeCoordinator; private readonly EventLogConcurrencyState _concurrencyState = concurrencyState; @@ -48,6 +59,7 @@ internal sealed class OpenLogEffects( private readonly ITraceLogger _logger = logger; private readonly ConcurrentDictionary _logLoadCompletions = new(); private readonly ILogWatcherService _logWatcherService = logWatcherService; + private readonly IEventLogReaderFactory _readerFactory = readerFactory; private readonly IEventResolverCache _resolverCache = resolverCache; private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory; private readonly IEventXmlResolver _xmlResolver = xmlResolver; @@ -289,10 +301,12 @@ private async Task LoadLogAsync( int resolved = 0; int lastPartialIndex = 0; int timerTick = 0; + int eagerFired = 0; + long highAdmitted = 0; dispatcher.Dispatch(new AddTableAction(logData)); - var channel = Channel.CreateBounded(new BoundedChannelOptions(s_maxGlobalConcurrency * 2) + var channel = Channel.CreateBounded<(long Seq, EventRecord[] Batch)>(new BoundedChannelOptions(s_maxGlobalConcurrency * 2) { SingleWriter = true, FullMode = BoundedChannelFullMode.Wait @@ -300,6 +314,11 @@ private async Task LoadLogAsync( List events = []; + var resolvedBySeq = new Dictionary>(); + long nextDrainSeq = 0; + + var partialDispatchGate = new object(); + await using var timer = new Timer( _ => { @@ -307,19 +326,7 @@ private async Task LoadLogAsync( if (Interlocked.Increment(ref timerTick) <= 1) { return; } - List delta; - - lock (events) - { - int fromIndex = Volatile.Read(ref lastPartialIndex); - - if (events.Count <= fromIndex) { return; } - - delta = events.GetRange(fromIndex, events.Count - fromIndex); - Volatile.Write(ref lastPartialIndex, events.Count); - } - - dispatcher.Dispatch(new LoadEventsPartialAction(logData, delta.AsReadOnly())); + TryDispatchPartial(); }, null, TimeSpan.Zero, @@ -327,19 +334,21 @@ private async Task LoadLogAsync( bool renderXml = _eventLogState.Value.AppliedFilter.RequiresXml; - using var reader = new EventLogReader(action.LogName, action.LogPathType, renderXml); + using var reader = _readerFactory.CreateReader(action.LogName, action.LogPathType, renderXml, reverseDirection: true); var producerTask = Task.Run(async () => { + long sequence = 0; + try { - while (reader.TryGetEvents(out EventRecord[] batch)) + while (reader.TryGetEvents(out EventRecord[] batch, ReadBatchSize)) { token.ThrowIfCancellationRequested(); if (batch.Length == 0) { continue; } - await channel.Writer.WriteAsync(batch, token); + await channel.Writer.WriteAsync((sequence++, batch), token); } } catch (Exception ex) @@ -354,6 +363,13 @@ private async Task LoadLogAsync( try { + if (!reader.IsValid) + { + int openError = reader.OpenErrorCode ?? 0; + + throw new Win32Exception(openError, $"Opening '{action.LogName}' failed (Win32 {openError}: {Marshal.GetPInvokeErrorMessage(openError)})."); + } + await Parallel.ForEachAsync( channel.Reader.ReadAllAsync(token), new ParallelOptions @@ -361,9 +377,23 @@ await Parallel.ForEachAsync( CancellationToken = token, MaxDegreeOfParallelism = s_maxGlobalConcurrency }, - async (batch, innerToken) => + async (item, innerToken) => { - await s_resolutionThrottle.WaitAsync(innerToken); + EventRecord[] batch = item.Batch; + + // First-screenful resolution preempts in-flight bulk across all loads. Classify by ADMITTED + // (not completed) events so a slow-resolving load still demotes to Bulk after its first + // screenful instead of monopolizing the high-priority lane. + var priority = Volatile.Read(ref highAdmitted) < EagerFirstPaintThreshold + ? ResolutionPriority.FirstScreenful + : ResolutionPriority.Bulk; + + if (priority == ResolutionPriority.FirstScreenful) + { + Interlocked.Add(ref highAdmitted, batch.Length); + } + + await s_resolutionGate.WaitAsync(priority, innerToken); try { @@ -394,22 +424,45 @@ await Parallel.ForEachAsync( } } - if (localBatch.Count > 0) + bool dispatchEager = false; + + lock (events) { - lock (events) { events.AddRange(localBatch); } + resolvedBySeq[item.Seq] = localBatch; - Interlocked.Add(ref resolved, localResolved); + while (resolvedBySeq.Remove(nextDrainSeq, out var ready)) + { + events.AddRange(ready); + nextDrainSeq++; + } + + // Eager first paint: once the first screenful of (newest, because the read is reversed) + // events has drained in order, dispatch immediately instead of waiting for the 3s timer. + // Fires exactly once across the resolve workers and reuses the shared partial cursor. + if (events.Count >= EagerFirstPaintThreshold && Interlocked.Exchange(ref eagerFired, 1) == 0) + { + dispatchEager = true; + } } + + Interlocked.Add(ref resolved, localResolved); + + if (dispatchEager) { TryDispatchPartial(); } } finally { - s_resolutionThrottle.Release(); + s_resolutionGate.Release(); } }); await producerTask; - lastEvent = reader.LastBookmark; + lastEvent = reader.NewestBookmark; + + if (reader.LastErrorCode is { } readErrorCode) + { + throw new Win32Exception(readErrorCode, $"Reading '{action.LogName}' stopped (Win32 {readErrorCode}: {Marshal.GetPInvokeErrorMessage(readErrorCode)})."); + } } catch (OperationCanceledException) { @@ -486,5 +539,27 @@ await Parallel.ForEachAsync( dispatcher.Dispatch(new SetResolverStatusAction(string.Empty)); _logger.Information($"Loaded '{action.LogName}': {events.Count} events ({failed} failed) in {stopwatch.ElapsedMilliseconds}ms."); + + return; + + void TryDispatchPartial() + { + lock (partialDispatchGate) + { + List delta; + + lock (events) + { + int fromIndex = lastPartialIndex; + + if (events.Count <= fromIndex) { return; } + + delta = events.GetRange(fromIndex, events.Count - fromIndex); + lastPartialIndex = events.Count; + } + + dispatcher.Dispatch(new LoadEventsPartialAction(logData, delta.AsReadOnly())); + } + } } } diff --git a/src/EventLogExpert.Runtime/Update/UpdateService.cs b/src/EventLogExpert.Runtime/Update/UpdateService.cs index 6d937f14e..64640a7b6 100644 --- a/src/EventLogExpert.Runtime/Update/UpdateService.cs +++ b/src/EventLogExpert.Runtime/Update/UpdateService.cs @@ -8,6 +8,7 @@ using EventLogExpert.Runtime.Settings; using EventLogExpert.Runtime.Update.Deployment; using EventLogExpert.Runtime.Update.ReleaseNotes; +using System.Runtime.InteropServices; namespace EventLogExpert.Runtime.Update; @@ -158,11 +159,23 @@ await alertDialogService.ShowAlert("Update Failure", try { - string downloadPath = latest.Value.Assets.First(x => x.Name.Contains(".msix")).Uri; + string? downloadPath = SelectUpdateDownloadUri(latest.Value.Assets); if (string.IsNullOrEmpty(downloadPath)) { - traceLogger.Warning($"{nameof(CheckForUpdates)} Could not get asset download path."); + string availableAssets = latest.Value.Assets is null or { Count: 0 } + ? "(none)" + : string.Join(", ", latest.Value.Assets.Select(asset => string.IsNullOrEmpty(asset.Name) ? "(unnamed)" : asset.Name)); + + traceLogger.Warning($"{nameof(CheckForUpdates)} No update bundle (.msixbundle) was found in the " + + $"latest release. OS architecture: {RuntimeInformation.OSArchitecture}. Available assets: {availableAssets}"); + + if (userInitiated) + { + await alertDialogService.ShowAlert("Update Unavailable", + "No compatible update package was found.", + "OK"); + } return; } @@ -216,4 +229,20 @@ await alertDialogService.ShowAlert("Release Notes Failure", return new ReleaseNotesContent(title, markdown); } + + /// + /// Selects the download URI of the multi-architecture app bundle (.msixbundle) from the release assets, or + /// when the release contains no bundle. + /// + internal static string? SelectUpdateDownloadUri(IReadOnlyList? assets) + { + return assets?.Where(asset => + asset.Name is not null && + (asset.Name.StartsWith("EventLogExpert_", StringComparison.OrdinalIgnoreCase) || + asset.Name.StartsWith("EventLogExpert.", StringComparison.OrdinalIgnoreCase)) && + asset.Name.EndsWith(".msixbundle", StringComparison.OrdinalIgnoreCase) && + !string.IsNullOrWhiteSpace(asset.Uri)) + .Select(asset => asset.Uri) + .FirstOrDefault(); + } } diff --git a/src/EventLogExpert/EventLogExpert.csproj b/src/EventLogExpert/EventLogExpert.csproj index db033d0ae..fb0f0bf89 100644 --- a/src/EventLogExpert/EventLogExpert.csproj +++ b/src/EventLogExpert/EventLogExpert.csproj @@ -34,6 +34,8 @@ $(DefineConstants);DISABLE_XAML_GENERATED_MAIN app.manifest + + true @@ -77,14 +79,18 @@ - x64 + ARM64 + ARM64 + x64 + + - + + <_ExplorerExtensionVcComponents>Microsoft.VisualStudio.Component.VC.Tools.x86.x64 + <_ExplorerExtensionVcComponents Condition="'$(ExplorerExtensionNativePlatform)' == 'ARM64'">Microsoft.VisualStudio.Component.VC.Tools.x86.x64 Microsoft.VisualStudio.Component.VC.Tools.ARM64 + + @@ -138,7 +158,7 @@ $(VsMSBuildPath.Trim()) + Text="Could not locate Visual Studio MSBuild.exe with the required C++ workload components via vswhere ($(_VswherePath)). The native shell extension requires a VS install with $(_ExplorerExtensionVcComponents); the Windows 10/11 SDK is supplied by the restored Microsoft.Windows.SDK.CPP* NuGet packages (or a machine-installed SDK as a fallback). See docs/Explorer-Context-Menu.md for prereqs." /> @@ -172,7 +192,7 @@ + Text="EventLogExpert.ExplorerExtension.dll native build did not produce output at $(ExplorerExtensionNativeDll). Check the C++ workload and the restored Microsoft.Windows.SDK.CPP* packages (or a machine-installed Windows SDK). See docs/Explorer-Context-Menu.md for prereqs." /> - - - - - diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/GlobalUsings.cs b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/GlobalUsings.cs deleted file mode 100644 index d6b475048..000000000 --- a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/GlobalUsings.cs +++ /dev/null @@ -1,8 +0,0 @@ -// // Copyright (c) Microsoft Corporation. -// // Licensed under the MIT License. - -global using Xunit; - -using EventLogExpert.EventDbTool.IntegrationTests; - -[assembly: AssemblyFixture(typeof(ContainerRequiredFixture))] diff --git a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/xunit.runner.json b/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/xunit.runner.json deleted file mode 100644 index 77778efd0..000000000 --- a/tests/Integration/EventLogExpert.EventDbTool.IntegrationTests/xunit.runner.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} diff --git a/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderReverseTests.cs b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderReverseTests.cs new file mode 100644 index 000000000..c33918833 --- /dev/null +++ b/tests/Integration/EventLogExpert.Eventing.IntegrationTests/Readers/EventLogReaderReverseTests.cs @@ -0,0 +1,179 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.Common.Channels; +using EventLogExpert.Eventing.Readers; +using EventLogExpert.Eventing.TestUtils.Constants; + +namespace EventLogExpert.Eventing.IntegrationTests.Readers; + +public sealed class EventLogReaderReverseTests +{ + [Fact] + public void ForwardRead_NewestBookmark_ShouldAliasLastBookmark() + { + using var fixture = new SmallEvtxFixture(); + using var reader = new EventLogReader(fixture.FilePath, LogPathType.File); + + while (reader.TryGetEvents(out _)) { } + + Assert.NotNull(reader.LastBookmark); + + // Forward never captures a separate newest bookmark; NewestBookmark returns LastBookmark directly. + Assert.Equal(reader.LastBookmark, reader.NewestBookmark); + } + + [Fact] + public void ForwardRead_ShouldReturnRecordIdsAscending() + { + using var fixture = new SmallEvtxFixture(); + using var reader = new EventLogReader(fixture.FilePath, LogPathType.File); + + var ids = ReadAllRecordIds(reader, batchSize: 1); + + Assert.True(ids.Count >= 2, "fixture should export at least two events"); + AssertStrictlyOrdered(ids, ascending: true); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void NewestBookmark_WhenInitialized_ShouldBeNull(bool reverseDirection) + { + using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel, reverseDirection: reverseDirection); + + Assert.Null(reader.NewestBookmark); + } + + [Fact] + public void NewestEvent_ShouldBeIdenticalRegardlessOfDirection() + { + using var fixture = new SmallEvtxFixture(); + using var forward = new EventLogReader(fixture.FilePath, LogPathType.File); + using var reverse = new EventLogReader(fixture.FilePath, LogPathType.File, reverseDirection: true); + + var forwardIds = ReadAllRecordIds(forward, batchSize: 30); + var reverseIds = ReadAllRecordIds(reverse, batchSize: 30); + + Assert.NotEmpty(forwardIds); + Assert.NotEmpty(reverseIds); + + // The newest event is the last one read forward and the first one read reverse (same RecordId). + Assert.Equal(forwardIds[^1], reverseIds[0]); + Assert.Equal(forwardIds.Max(), reverseIds.Max()); + Assert.Equal(forwardIds.Min(), reverseIds.Min()); + } + + [Fact] + public void ReverseRead_NewestBookmark_ShouldBeStableAcrossBatches() + { + using var fixture = new SmallEvtxFixture(); + using var reader = new EventLogReader(fixture.FilePath, LogPathType.File, reverseDirection: true); + + Assert.True(reader.TryGetEvents(out var firstBatch, 1)); + Assert.Single(firstBatch); + + string? newestAfterFirstBatch = reader.NewestBookmark; + Assert.NotNull(newestAfterFirstBatch); + + while (reader.TryGetEvents(out _, 1)) { } + + // The newest bookmark is captured once on the first batch and must not drift to a later (older) batch. + Assert.Equal(newestAfterFirstBatch, reader.NewestBookmark); + } + + [Fact] + public void ReverseRead_NewestBookmark_ShouldDifferFromLastBookmark() + { + using var fixture = new SmallEvtxFixture(); + using var reader = new EventLogReader(fixture.FilePath, LogPathType.File, reverseDirection: true); + + while (reader.TryGetEvents(out _, 1)) { } + + Assert.NotNull(reader.NewestBookmark); + Assert.NotNull(reader.LastBookmark); + + // For a multi-event log read newest-first, NewestBookmark (newest) and LastBookmark (oldest enumerated) differ. + Assert.NotEqual(reader.NewestBookmark, reader.LastBookmark); + } + + [Fact] + public void ReverseRead_OnLiveChannel_FirstBatchShouldBeNewestFirst() + { + using var reader = new EventLogReader(Constants.ApplicationLogName, LogPathType.Channel, reverseDirection: true); + + Assert.True(reader.TryGetEvents(out var batch, 10)); + + var ids = batch.Where(evt => evt.RecordId is not null).Select(evt => evt.RecordId!.Value).ToList(); + + Assert.NotEmpty(ids); + AssertStrictlyOrdered(ids, ascending: false); + Assert.NotNull(reader.NewestBookmark); + } + + [Fact] + public void ReverseRead_ShouldReturnRecordIdsDescendingAcrossBatches() + { + using var fixture = new SmallEvtxFixture(); + using var reader = new EventLogReader(fixture.FilePath, LogPathType.File, reverseDirection: true); + + // batchSize 1 forces every event into its own batch, so this also proves cross-batch monotonicity + // (the last RecordId of batch N is greater than the first RecordId of batch N+1). + var ids = ReadAllRecordIds(reader, batchSize: 1); + + Assert.True(ids.Count >= 2, "fixture should export at least two events"); + AssertStrictlyOrdered(ids, ascending: false); + } + + [Fact] + public void ReverseRead_SingleEventFirstBatch_ShouldCaptureNewestBookmarkWithoutCrashing() + { + using var fixture = new SmallEvtxFixture(); + using var reader = new EventLogReader(fixture.FilePath, LogPathType.File, reverseDirection: true); + + Assert.True(reader.TryGetEvents(out var batch, 1)); + Assert.Single(batch); + + // buffer[0] == buffer[count - 1] here: both bookmarks are taken from the same single (newest) handle, + // each wrapped non-owning, so the double-wrap must not double-free or crash. + Assert.NotNull(reader.NewestBookmark); + Assert.NotNull(reader.LastBookmark); + } + + [Fact] + public void ReverseRead_WhenInvalidLog_ShouldFailWithoutBookmark() + { + using var reader = new EventLogReader("NonExistentLog_" + Guid.NewGuid(), LogPathType.Channel, reverseDirection: true); + + Assert.False(reader.TryGetEvents(out var events)); + Assert.Empty(events); + Assert.Null(reader.NewestBookmark); + } + + private static void AssertStrictlyOrdered(IReadOnlyList ids, bool ascending) + { + for (int i = 1; i < ids.Count; i++) + { + bool ordered = ascending ? ids[i] > ids[i - 1] : ids[i] < ids[i - 1]; + + Assert.True( + ordered, + $"RecordIds not strictly {(ascending ? "ascending" : "descending")} at index {i}: {ids[i - 1]} then {ids[i]}"); + } + } + + private static List ReadAllRecordIds(EventLogReader reader, int batchSize) + { + var ids = new List(); + + while (reader.TryGetEvents(out var batch, batchSize)) + { + foreach (var evt in batch) + { + if (evt.RecordId is { } id) { ids.Add(id); } + } + } + + return ids; + } +} diff --git a/tests/Integration/EventLogExpert.Filtering.IntegrationTests/EventLogExpert.Filtering.IntegrationTests.csproj b/tests/Integration/EventLogExpert.Filtering.IntegrationTests/EventLogExpert.Filtering.IntegrationTests.csproj deleted file mode 100644 index 2345249d3..000000000 --- a/tests/Integration/EventLogExpert.Filtering.IntegrationTests/EventLogExpert.Filtering.IntegrationTests.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - false - true - - - - - - - - - - - diff --git a/tests/Integration/EventLogExpert.Filtering.IntegrationTests/xunit.runner.json b/tests/Integration/EventLogExpert.Filtering.IntegrationTests/xunit.runner.json deleted file mode 100644 index 77778efd0..000000000 --- a/tests/Integration/EventLogExpert.Filtering.IntegrationTests/xunit.runner.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/EventLogExpert.UI.IntegrationTests.csproj b/tests/Integration/EventLogExpert.UI.IntegrationTests/EventLogExpert.UI.IntegrationTests.csproj deleted file mode 100644 index 170c99d53..000000000 --- a/tests/Integration/EventLogExpert.UI.IntegrationTests/EventLogExpert.UI.IntegrationTests.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - false - true - - - - - - - - - - - diff --git a/tests/Integration/EventLogExpert.UI.IntegrationTests/xunit.runner.json b/tests/Integration/EventLogExpert.UI.IntegrationTests/xunit.runner.json deleted file mode 100644 index 77778efd0..000000000 --- a/tests/Integration/EventLogExpert.UI.IntegrationTests/xunit.runner.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://xunit.net/schema/v3/xunit.runner.schema.json", - "parallelizeAssembly": false, - "parallelizeTestCollections": false -} diff --git a/tests/Integration/README.md b/tests/Integration/README.md index 91d113905..08f83b833 100644 --- a/tests/Integration/README.md +++ b/tests/Integration/README.md @@ -20,7 +20,7 @@ ensures tests fail loudly rather than silently polluting your local Application ### To run integration tests Use the provided script, which handles Docker daemon mode switching automatically. -The script runs all services defined in `compose.yml` (eventing, runtime, eventdbtool): +The script runs all services defined in `compose.yml` (eventing, runtime, elevationhelper): ```powershell # Run all container-gated suites @@ -28,7 +28,7 @@ The script runs all services defined in `compose.yml` (eventing, runtime, eventd # Run a specific suite ./scripts/run-tests.ps1 -Suite eventing -./scripts/run-tests.ps1 -Suite runtime,eventdbtool +./scripts/run-tests.ps1 -Suite runtime,elevationhelper ``` Integration test projects that do not require a Windows container (e.g., ProviderDatabase) @@ -47,7 +47,7 @@ Or run Docker Compose directly: ```powershell docker compose run --rm eventing docker compose run --rm runtime -docker compose run --rm eventdbtool +docker compose run --rm elevationhelper ``` Or opt in on host explicitly (accepts event log pollution): @@ -93,7 +93,7 @@ Run each integration test project **serially**: ```powershell docker compose run --rm eventing docker compose run --rm runtime -docker compose run --rm eventdbtool +docker compose run --rm elevationhelper ``` Do **not** use `docker compose up`. The three services share the `build-artifacts` diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs index 36658b29c..277ad319b 100644 --- a/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs +++ b/tests/Unit/EventLogExpert.Eventing.Tests/Interop/NativeMethodsEvtTests.cs @@ -2,6 +2,7 @@ // // Licensed under the MIT License. using EventLogExpert.Eventing.Interop; +using EventLogExpert.Eventing.Readers; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; @@ -402,10 +403,11 @@ public void ConvertVariant_WhenNull_ShouldReturnNull() } [Fact] - public void ConvertVariant_WhenSByte_ShouldReturnByte() + public void ConvertVariant_WhenSByte_ShouldReturnSByte() { - // Arrange - byte expectedValue = 127; + // Arrange - a negative value distinguishes the signed INT8 result from the prior byte + // return (the same 0xFF byte reads as -1 via SByteVal, but 255 if read as a byte). + sbyte expectedValue = -1; var variant = CreateVariant(EvtVariantType.SByte, expectedValue); // Act @@ -413,7 +415,7 @@ public void ConvertVariant_WhenSByte_ShouldReturnByte() // Assert Assert.NotNull(result); - Assert.IsType(result); + Assert.IsType(result); Assert.Equal(expectedValue, result); } @@ -447,10 +449,10 @@ public void ConvertVariant_WhenSingle_ShouldReturnFloat() } [Fact] - public void ConvertVariant_WhenSizeT_ShouldReturnIntPtr() + public void ConvertVariant_WhenSizeT_ShouldReturnUIntPtr() { // Arrange - nint expectedValue = 12345; + nuint expectedValue = 12345; var variant = CreateVariant(EvtVariantType.SizeT, expectedValue); // Act @@ -458,7 +460,7 @@ public void ConvertVariant_WhenSizeT_ShouldReturnIntPtr() // Assert Assert.NotNull(result); - Assert.IsType(result); + Assert.IsType(result); Assert.Equal(expectedValue, result); } @@ -745,6 +747,133 @@ public void ConvertVariant_WhenXml_ShouldReturnString() } } + [Fact] + public void EvtVariant_PointerIndexedRead_ShouldMatchMarshalledRead_ForInteriorPointerTypes() + { + // RenderEventProperties / GetEventRecord now read contiguous EvtVariant buffers via + // ((EvtVariant*)ptr)[i] instead of Marshal.PtrToStructure. Verify the pointer-indexed read + // is equivalent to the marshalled read at non-zero indices (stride-sensitive) and that + // ConvertVariant dereferences each variant's interior pointer (string/systime) correctly. + // String and SysTime are interior-pointer types, exercised at indices 0, 1 and 2. + const string firstString = "First property value"; + const string thirdString = "Third property value"; + var expectedDateTime = new DateTime(2024, 3, 15, 14, 30, 45, 123, DateTimeKind.Utc); + + IntPtr firstStringPtr = Marshal.StringToHGlobalUni(firstString); + IntPtr thirdStringPtr = Marshal.StringToHGlobalUni(thirdString); + IntPtr sysTimePtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + int variantSize = Marshal.SizeOf(); + IntPtr buffer = Marshal.AllocHGlobal(variantSize * 3); + + try + { + unsafe + { + short* sysTime = (short*)sysTimePtr; + sysTime[0] = 2024; // Year + sysTime[1] = 3; // Month + sysTime[2] = 5; // DayOfWeek + sysTime[3] = 15; // Day + sysTime[4] = 14; // Hour + sysTime[5] = 30; // Minute + sysTime[6] = 45; // Second + sysTime[7] = 123; // Milliseconds + + Unsafe.InitBlock((void*)buffer, 0, (uint)(variantSize * 3)); + WriteVariantSlot(buffer, 0, variantSize, EvtVariantType.String, firstStringPtr); + WriteVariantSlot(buffer, 1, variantSize, EvtVariantType.SysTime, sysTimePtr); + WriteVariantSlot(buffer, 2, variantSize, EvtVariantType.String, thirdStringPtr); + + for (int i = 0; i < 3; i++) + { + var pointerRead = ((EvtVariant*)buffer)[i]; + var marshalledRead = Marshal.PtrToStructure(buffer + (i * variantSize)); + + Assert.Equal(NativeMethods.ConvertVariant(marshalledRead), NativeMethods.ConvertVariant(pointerRead)); + } + + Assert.Equal(firstString, NativeMethods.ConvertVariant(((EvtVariant*)buffer)[0])); + Assert.Equal(expectedDateTime, NativeMethods.ConvertVariant(((EvtVariant*)buffer)[1])); + Assert.Equal(thirdString, NativeMethods.ConvertVariant(((EvtVariant*)buffer)[2])); + } + } + finally + { + Marshal.FreeHGlobal(buffer); + Marshal.FreeHGlobal(sysTimePtr); + Marshal.FreeHGlobal(thirdStringPtr); + Marshal.FreeHGlobal(firstStringPtr); + } + } + + [Fact] + public void EvtVariant_ShouldRemainBlittableWithStableLayout() + { + // The render path reads contiguous EvtVariant buffers directly through a pointer + // (((EvtVariant*)ptr)[i]), so the struct must stay blittable and keep the native + // EVT_VARIANT layout: no managed references, a managed pointer stride (Unsafe.SizeOf) + // equal to the native marshalled stride (Marshal.SizeOf), and Count/Type at offsets 8/12. + // A future field that breaks any of these fails here instead of mis-indexing at runtime. + Assert.False(RuntimeHelpers.IsReferenceOrContainsReferences()); + Assert.Equal(16, Unsafe.SizeOf()); + Assert.Equal(16, Marshal.SizeOf()); + Assert.Equal(Marshal.SizeOf(), Unsafe.SizeOf()); + Assert.Equal(8, (int)Marshal.OffsetOf(nameof(EvtVariant.Count))); + Assert.Equal(12, (int)Marshal.OffsetOf(nameof(EvtVariant.Type))); + } + + [Fact] + public void GetEventRecord_WhenActivityIdNullVsPresent_ShouldPreserveDistinction() + { + var expected = Guid.NewGuid(); + IntPtr guidPtr = Marshal.AllocHGlobal(Marshal.SizeOf()); + + try + { + Marshal.StructureToPtr(expected, guidPtr, false); + + var absent = BuildAndReadSystemRecord((buffer, size) => + SetSlotType(buffer, (int)EvtSystemPropertyId.ActivityId, size, EvtVariantType.Null)); + var present = BuildAndReadSystemRecord((buffer, size) => + WriteVariantSlot(buffer, (int)EvtSystemPropertyId.ActivityId, size, EvtVariantType.Guid, guidPtr)); + + Assert.Null(absent.ActivityId); + Assert.Equal(expected, present.ActivityId); + } + finally + { + Marshal.FreeHGlobal(guidPtr); + } + } + + [Fact] + public void GetEventRecord_WhenKeywordsHighBitSet_ShouldReinterpretAsNegativeLong() + { + // Keywords renders as HexInt64; the unchecked (long) reinterpret must preserve the bits, so a + // high-bit-set UInt64 becomes a negative long rather than throwing or clamping. + const ulong highBit = 0x8000_0000_0000_0001; + var record = BuildAndReadSystemRecord((buffer, size) => + SetSlotScalarUInt64(buffer, (int)EvtSystemPropertyId.Keywords, size, EvtVariantType.HexInt64, highBit)); + + Assert.Equal(unchecked((long)highBit), record.Keywords); + Assert.True(record.Keywords < 0); + } + + [Fact] + public void GetEventRecord_WhenNullableTaskNullVsZero_ShouldPreserveDistinction() + { + // The absent-vs-zero distinction is load-bearing: Type=Null means the property is absent (-> null); + // Type=UInt16 value 0 means present-and-zero (-> 0). Reading the field without the Null guard would + // collapse both to 0. + var absent = BuildAndReadSystemRecord((buffer, size) => + SetSlotType(buffer, (int)EvtSystemPropertyId.Task, size, EvtVariantType.Null)); + var zero = BuildAndReadSystemRecord((buffer, size) => + SetSlotScalarUInt16(buffer, (int)EvtSystemPropertyId.Task, size, 0)); + + Assert.Null(absent.Task); + Assert.Equal((ushort)0, zero.Task); + } + [Fact] public void ThrowEventLogException_WhenAccessDenied_ShouldThrowUnauthorizedAccessException() { @@ -875,7 +1004,33 @@ public void ThrowEventLogException_WhenUnknownError_ShouldThrowException() Assert.Throws(() => NativeMethods.ThrowEventLogException(error)); } - // Helper methods to create EvtVariant instances + // Builds an 18-slot EVT_VARIANT system-property buffer (every slot Null), pre-sets the required EventId + + // TimeCreated slots to valid values so GetEventRecord's asserted/dereferenced required reads don't fire, + // applies the per-test configuration, then reads it back through GetEventRecord. + private static unsafe EventRecord BuildAndReadSystemRecord(Action configure) + { + int variantSize = Marshal.SizeOf(); + const int count = 18; + IntPtr buffer = Marshal.AllocHGlobal(variantSize * count); + + try + { + Unsafe.InitBlock((void*)buffer, 0, (uint)(variantSize * count)); + + SetSlotScalarUInt16(buffer, (int)EvtSystemPropertyId.EventId, variantSize, 1000); + SetSlotScalarUInt64(buffer, (int)EvtSystemPropertyId.TimeCreated, variantSize, EvtVariantType.FileTime, + (ulong)new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc).ToFileTimeUtc()); + + configure(buffer, variantSize); + + return NativeMethods.GetEventRecord(buffer, count); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } + private static EvtVariant CreateVariant(EvtVariantType type, object? value = null) { return CreateVariantWithCount(type, value, 0); @@ -921,6 +1076,8 @@ private static EvtVariant CreateVariantWithCount(EvtVariantType type, object? va *(nint*)buffer = (IntPtr)value; break; case EvtVariantType.SByte: + *(sbyte*)buffer = (sbyte)value; + break; case EvtVariantType.Byte: *(byte*)buffer = (byte)value; break; @@ -953,7 +1110,7 @@ private static EvtVariant CreateVariantWithCount(EvtVariantType type, object? va *(double*)buffer = (double)value; break; case EvtVariantType.SizeT: - *(nint*)buffer = (nint)value; + *(nuint*)buffer = (nuint)value; break; } @@ -965,4 +1122,27 @@ private static EvtVariant CreateVariantWithCount(EvtVariantType type, object? va Marshal.FreeHGlobal(buffer); } } + + private static unsafe void SetSlotScalarUInt16(IntPtr buffer, int index, int variantSize, ushort value) + { + *(ushort*)(buffer + (index * variantSize)) = value; + SetSlotType(buffer, index, variantSize, EvtVariantType.UInt16); + } + + private static unsafe void SetSlotScalarUInt64(IntPtr buffer, int index, int variantSize, EvtVariantType type, ulong value) + { + *(ulong*)(buffer + (index * variantSize)) = value; + SetSlotType(buffer, index, variantSize, type); + } + + private static unsafe void SetSlotType(IntPtr buffer, int index, int variantSize, EvtVariantType type) => + *(uint*)(buffer + (index * variantSize) + 12) = (uint)type; + + // Helper methods to create EvtVariant instances + private static unsafe void WriteVariantSlot(IntPtr buffer, int index, int variantSize, EvtVariantType type, IntPtr value) + { + IntPtr slot = buffer + (index * variantSize); + *(nint*)slot = value; // interior pointer (union) at offset 0 + *(uint*)(slot + 12) = (uint)type; // Type at offset 12 + } } diff --git a/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/MessageTableReaderTests.cs b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/MessageTableReaderTests.cs new file mode 100644 index 000000000..75f74bf05 --- /dev/null +++ b/tests/Unit/EventLogExpert.Eventing.Tests/PublisherMetadata/MessageTableReaderTests.cs @@ -0,0 +1,200 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Eventing.PublisherMetadata; +using EventLogExpert.Provider.Resolution; +using System.Runtime.InteropServices; +using System.Text; + +namespace EventLogExpert.Eventing.Tests.PublisherMetadata; + +public sealed class MessageTableReaderTests +{ + [Fact] + public void AppendMatches_AppendsEveryEntryInWalkOrder_WhenFilterIsNegative() + { + byte[] table = BuildTable((5, [("five", false), ("six", true)])); + + WithTable(table, (memTable, size) => + { + var into = new List(); + MessageTableReader.AppendMatches(memTable, size, "P", -1, into); + + Assert.Equal(2, into.Count); + Assert.Equal("five", into[0].Text); + Assert.Equal(5, into[0].RawId); + Assert.Equal("six", into[1].Text); + Assert.Equal(6, into[1].RawId); + }); + } + + [Fact] + public void AppendMatches_ReturnsAllLow16Collisions_InBlockOrder() + { + // Two ids whose low 16 bits are both 5 (5 and 0x10005), in separate blocks - the qualifier/severity case the + // eager store keys by (ushort)ShortId. GetByShortId(5) must return both, in walk order. + byte[] table = BuildTable( + (5, [("low", false)]), + (0x10005, [("high", false)])); + + WithTable(table, (memTable, size) => + { + var into = new List(); + MessageTableReader.AppendMatches(memTable, size, "P", 5, into); + + Assert.Equal(["low", "high"], into.Select(m => m.Text)); + Assert.Equal([5L, 0x10005L], into.Select(m => m.RawId)); + }); + } + + [Fact] + public void CountEntries_CountsEveryEntryAcrossBlocks() + { + byte[] table = BuildTable( + (10, [("ten", false), ("eleven", false)]), + (100, [("hundred", false)])); + + WithTable(table, (memTable, size) => Assert.Equal(3, MessageTableReader.CountEntries(memTable, size))); + } + + [Fact] + public void CountEntries_ReturnsNegative_WhenOffsetIsOutOfBounds() + { + byte[] table = BuildTable((1, [("one", false)])); + + // Corrupt the first block's OffsetToEntries (bytes 12-15) to point past the resource. + BitConverter.GetBytes(0x7FFF_FFFF).CopyTo(table, 12); + + WithTable(table, (memTable, size) => Assert.Equal(-1, MessageTableReader.CountEntries(memTable, size))); + } + + [Fact] + public void FindFirstByRawId_ReturnsExactRawIdMatch() + { + byte[] table = BuildTable((5, [("five", false)]), (0x10005, [("high", false)])); + + WithTable(table, (memTable, size) => + { + Assert.Equal("high", MessageTableReader.FindFirstByRawId(memTable, size, 0x10005, "P")?.Text); + Assert.Null(MessageTableReader.FindFirstByRawId(memTable, size, 7, "P")); + }); + } + + [Fact] + public void LazySource_MaterializeAll_MatchesEagerLoadOnRealProviderDll() + { + string dll = Path.Combine(Environment.SystemDirectory, "netmsg.dll"); + Assert.True(File.Exists(dll)); + + var eager = EventMessageProvider.LoadMessagesFromFiles([dll], "TestProvider"); + Assert.NotEmpty(eager); + + var lazy = new LegacyMessageFileSource([dll], "TestProvider", eager.Count, null); + + Assert.Equal(eager.Count, lazy.Count); + + var materialized = lazy.MaterializeAll(); + Assert.Equal(eager.Count, materialized.Count); + for (int i = 0; i < eager.Count; i++) + { + Assert.Equal(eager[i].ShortId, materialized[i].ShortId); + Assert.Equal(eager[i].RawId, materialized[i].RawId); + Assert.Equal(eager[i].Text, materialized[i].Text); + } + + // Per-id extraction must reproduce the eager store's by-ShortId and first-by-RawId lookups exactly. + foreach (var shortId in eager.Select(m => (int)(ushort)m.ShortId).Distinct().Take(50)) + { + var expected = eager.Where(m => (ushort)m.ShortId == shortId).Select(m => m.Text); + Assert.Equal(expected, lazy.GetByShortId(shortId).Select(m => m.Text)); + } + + foreach (var rawId in eager.Select(m => m.RawId).Distinct().Take(50)) + { + var expected = eager.First(m => m.RawId == rawId); + Assert.Equal(expected.Text, lazy.GetByRawIdFirst(rawId)?.Text); + } + } + + [Fact] + public void LegacyMessageFileSource_SkipsUnloadableFiles_AndConcatenatesFilesInOrder() + { + string dll = Path.Combine(Environment.SystemDirectory, "netmsg.dll"); + string missing = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.dll"); + + var single = EventMessageProvider.LoadMessagesFromFiles([dll], "P"); + Assert.NotEmpty(single); + + // An unloadable file is skipped, not fatal. + var skipped = new LegacyMessageFileSource([missing, dll], "P", single.Count, null).MaterializeAll(); + Assert.Equal(single.Select(m => m.Text), skipped.Select(m => m.Text)); + + // Multiple files concatenate in order: the same file twice yields its entries twice, file1 before file2. + var doubled = new LegacyMessageFileSource([dll, dll], "P", single.Count * 2, null).MaterializeAll(); + Assert.Equal(single.Count * 2, doubled.Count); + Assert.Equal(single[0].Text, doubled[0].Text); + Assert.Equal(single[0].Text, doubled[single.Count].Text); + } + + [Fact] + public void TryOpen_ReturnsFalse_WhenFileIsNotAResourceModule() + { + string temp = Path.Combine(Path.GetTempPath(), $"not-a-dll-{Guid.NewGuid():N}.txt"); + File.WriteAllText(temp, "not a PE file"); + + try + { + Assert.False(MessageTableReader.TryOpen(temp, null, out var handle, out _, out _)); + handle.Dispose(); + } + finally { File.Delete(temp); } + } + + // Builds a MESSAGE_RESOURCE_DATA blob: a block count, then one MESSAGE_RESOURCE_BLOCK per block (LowId, HighId, + // OffsetToEntries), then the variable-length entries. Each block's ids run lowId..lowId+entries.Length-1. + private static byte[] BuildTable(params (int lowId, (string text, bool unicode)[] entries)[] blocks) + { + int headerSize = 4 + (12 * blocks.Length); + var entryBytes = new List(); + var offsets = new int[blocks.Length]; + + for (int b = 0; b < blocks.Length; b++) + { + offsets[b] = headerSize + entryBytes.Count; + + foreach (var (text, unicode) in blocks[b].entries) + { + byte[] encoded = unicode + ? Encoding.Unicode.GetBytes(text + '\0') + : Encoding.ASCII.GetBytes(text + '\0'); + + short length = (short)(4 + encoded.Length); + entryBytes.AddRange(BitConverter.GetBytes(length)); + entryBytes.AddRange(BitConverter.GetBytes((short)(unicode ? 1 : 0))); + entryBytes.AddRange(encoded); + } + } + + var result = new List(); + result.AddRange(BitConverter.GetBytes(blocks.Length)); + + for (int b = 0; b < blocks.Length; b++) + { + result.AddRange(BitConverter.GetBytes(blocks[b].lowId)); + result.AddRange(BitConverter.GetBytes(blocks[b].lowId + blocks[b].entries.Length - 1)); + result.AddRange(BitConverter.GetBytes(offsets[b])); + } + + result.AddRange(entryBytes); + + return [.. result]; + } + + private static void WithTable(byte[] table, Action action) + { + var handle = GCHandle.Alloc(table, GCHandleType.Pinned); + + try { action(handle.AddrOfPinnedObject(), (uint)table.Length); } + finally { handle.Free(); } + } +} diff --git a/tests/Unit/EventLogExpert.Provider.Tests/Resolution/ProviderDetailsTests.cs b/tests/Unit/EventLogExpert.Provider.Tests/Resolution/ProviderDetailsTests.cs index 15ee18672..93ba9570d 100644 --- a/tests/Unit/EventLogExpert.Provider.Tests/Resolution/ProviderDetailsTests.cs +++ b/tests/Unit/EventLogExpert.Provider.Tests/Resolution/ProviderDetailsTests.cs @@ -100,6 +100,45 @@ public void GetMessagesByShortId_WhenIdDoesNotExist_ReturnsEmptyList() Assert.Empty(result); } + [Fact] + public void GetMessagesByShortId_WhenMessageHasRareFields_PreservesThemByteIdentical() + { + // Arrange + var details = EventUtils.CreateProvider( + Constants.TestProviderLongName, + [new MessageModel { ShortId = 9, RawId = 9, Text = "rare", LogLink = "System", Tag = "t", Template = "" }]); + + // Act + var message = Assert.Single(details.GetMessagesByShortId(9)); + + // Assert + Assert.Equal("rare", message.Text); + Assert.Equal("System", message.LogLink); + Assert.Equal("t", message.Tag); + Assert.Equal("", message.Template); + Assert.Equal(9, message.RawId); + } + + [Fact] + public void GetMessagesByShortId_WhenMultipleShareShortId_PreservesOriginalOrder() + { + // Arrange + var details = EventUtils.CreateProvider( + Constants.TestProviderLongName, + [ + new MessageModel { ShortId = 7, RawId = 0x00010007, Text = "first" }, + new MessageModel { ShortId = 7, RawId = 0x00020007, Text = "second" }, + new MessageModel { ShortId = 7, RawId = 0x00030007, Text = "third" } + ]); + + // Act + var result = details.GetMessagesByShortId(7); + + // Assert + Assert.Equal(["first", "second", "third"], result.Select(m => m.Text)); + Assert.Equal([0x00010007, 0x00020007, 0x00030007], result.Select(m => m.RawId)); + } + [Fact] public void GetMessagesByShortId_WhenNegativeShortId_MatchesViaUnsignedPromotion() { @@ -248,6 +287,30 @@ public void IsEmpty_WhenTasksPopulated_ReturnsFalse() Assert.False(details.IsEmpty); } + [Fact] + public void Messages_ViewEnumeration_PreservesEveryFieldForCommonAndRareEntries() + { + // Arrange + var details = EventUtils.CreateProvider( + Constants.TestProviderLongName, + [ + new MessageModel { ShortId = 1, RawId = 1, Text = "common", ProviderName = Constants.TestProviderLongName }, + new MessageModel { ShortId = 2, RawId = 2, Text = "rare", LogLink = "Application", Tag = "x", ProviderName = Constants.TestProviderLongName } + ]); + + // Act + var all = details.Messages.ToList(); + + // Assert + Assert.Equal(2, details.Messages.Count); + Assert.Equal("common", all[0].Text); + Assert.Null(all[0].LogLink); + Assert.Equal(Constants.TestProviderLongName, all[0].ProviderName); + Assert.Equal("rare", all[1].Text); + Assert.Equal("Application", all[1].LogLink); + Assert.Equal("x", all[1].Tag); + } + [Fact] public void MessagesSetter_InvalidatesCachedLookup() { diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Concurrency/PrioritySemaphoreTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Concurrency/PrioritySemaphoreTests.cs new file mode 100644 index 000000000..255902598 --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Concurrency/PrioritySemaphoreTests.cs @@ -0,0 +1,204 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Concurrency; + +namespace EventLogExpert.Runtime.Tests.Concurrency; + +public sealed class PrioritySemaphoreTests +{ + [Fact] + public void PermitCount_IsConserved_AcrossCancellationAndRelease() + { + var semaphore = new PrioritySemaphore(1); + semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); + + using var cts = new CancellationTokenSource(); + semaphore.WaitAsync(ResolutionPriority.FirstScreenful, cts.Token); + var live = semaphore.WaitAsync(ResolutionPriority.FirstScreenful, CancellationToken.None); + cts.Cancel(); + + semaphore.Release(); // serves the live waiter (skipping the canceled one) + Assert.True(live.IsCompletedSuccessfully); + + semaphore.Release(); // the live waiter's permit returns + Assert.Equal(1, semaphore.CurrentCount); + + // Exactly `permits` (1) uncontended acquire succeeds; the next blocks. + Assert.True(semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None).IsCompletedSuccessfully); + Assert.False(semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None).IsCompleted); + } + + [Fact] + public void Release_DrainsAccumulatedCanceledWaiters_AndRestoresPermit() + { + const int permits = 1; + var semaphore = new PrioritySemaphore(permits); + semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); // consume the permit + + using var cts = new CancellationTokenSource(); + var waiters = new List(); + for (int i = 0; i < 5; i++) + { + waiters.Add(semaphore.WaitAsync(ResolutionPriority.FirstScreenful, cts.Token)); + } + + cts.Cancel(); + Assert.All(waiters, waiter => Assert.True(waiter.IsCanceled)); + + semaphore.Release(); // scans past every canceled waiter, finds no live one, restores the permit + + Assert.Equal(permits, semaphore.CurrentCount); + Assert.Equal((0, 0), semaphore.WaiterCounts); // the release scan dequeued the canceled waiters + } + + [Fact] + public void Release_IsFifoWithinAPriorityClass() + { + var semaphore = new PrioritySemaphore(1); + semaphore.WaitAsync(ResolutionPriority.FirstScreenful, CancellationToken.None); // consume the permit + + var first = semaphore.WaitAsync(ResolutionPriority.FirstScreenful, CancellationToken.None); + var second = semaphore.WaitAsync(ResolutionPriority.FirstScreenful, CancellationToken.None); + + semaphore.Release(); + Assert.True(first.IsCompletedSuccessfully); + Assert.False(second.IsCompleted); + + semaphore.Release(); + Assert.True(second.IsCompletedSuccessfully); + } + + [Fact] + public void Release_PrefersFirstScreenfulOverBulk() + { + var semaphore = new PrioritySemaphore(1); + semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); // consume the only permit + + var bulk = semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); + var firstScreenful = semaphore.WaitAsync(ResolutionPriority.FirstScreenful, CancellationToken.None); + Assert.False(bulk.IsCompleted); + Assert.False(firstScreenful.IsCompleted); + Assert.Equal((1, 1), semaphore.WaiterCounts); + + semaphore.Release(); + + Assert.True(firstScreenful.IsCompletedSuccessfully); + Assert.False(bulk.IsCompleted); + Assert.Equal(0, semaphore.CurrentCount); + } + + [Fact] + public void Release_SkipsCanceledWaiter_AndServesLiveWaiterWithoutLosingThePermit() + { + var semaphore = new PrioritySemaphore(1); + semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); // consume the permit + + using var cts = new CancellationTokenSource(); + var canceled = semaphore.WaitAsync(ResolutionPriority.FirstScreenful, cts.Token); + var live = semaphore.WaitAsync(ResolutionPriority.FirstScreenful, CancellationToken.None); + cts.Cancel(); + Assert.True(canceled.IsCanceled); + + semaphore.Release(); + + Assert.True(live.IsCompletedSuccessfully); + Assert.Equal(0, semaphore.CurrentCount); // permit transferred to the live waiter, not lost + } + + [Fact] + public void Release_SkipsManyCanceledHighs_AndServesLiveLow() + { + var semaphore = new PrioritySemaphore(1); + semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); // consume the permit + + using var cts = new CancellationTokenSource(); + var canceledHighs = new List(); + for (int i = 0; i < 3; i++) + { + canceledHighs.Add(semaphore.WaitAsync(ResolutionPriority.FirstScreenful, cts.Token)); + } + + var liveLow = semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); + cts.Cancel(); + Assert.All(canceledHighs, high => Assert.True(high.IsCanceled)); + + semaphore.Release(); // skip the canceled highs, then serve the live low + + Assert.True(liveLow.IsCompletedSuccessfully); + Assert.Equal(0, semaphore.CurrentCount); + } + + [Fact] + public void Release_WithNoWaiters_RestoresPermit() + { + var semaphore = new PrioritySemaphore(1); + semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); + Assert.Equal(0, semaphore.CurrentCount); + + semaphore.Release(); + Assert.Equal(1, semaphore.CurrentCount); + + Assert.True(semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None).IsCompletedSuccessfully); + } + + [Fact] + public async Task Stress_ConcurrentAcquireReleaseWithCancellation_NeverOverGrantsAndConservesPermits() + { + const int permits = 4; + var semaphore = new PrioritySemaphore(permits); + int concurrentlyHeld = 0; + int maxObserved = 0; + + async Task Worker(int seed) + { + var seededRandom = new Random(seed); + + for (int i = 0; i < 2000; i++) + { + var priority = seededRandom.Next(2) == 0 ? ResolutionPriority.FirstScreenful : ResolutionPriority.Bulk; + using var cts = new CancellationTokenSource(); + + if (seededRandom.Next(5) == 0) { cts.CancelAfter(TimeSpan.FromMilliseconds(seededRandom.Next(3))); } + + try { await semaphore.WaitAsync(priority, cts.Token); } + catch (OperationCanceledException) { continue; } + + try + { + int held = Interlocked.Increment(ref concurrentlyHeld); + int observed; + while (held > (observed = Volatile.Read(ref maxObserved)) && + Interlocked.CompareExchange(ref maxObserved, held, observed) != observed) { } + + await Task.Yield(); + Interlocked.Decrement(ref concurrentlyHeld); + } + finally { semaphore.Release(); } + } + } + + await Task.WhenAll(Enumerable.Range(0, 8).Select(seed => Task.Run(() => Worker(seed)))); + + Assert.True(maxObserved <= permits, $"concurrent grants {maxObserved} exceeded permits {permits}"); + Assert.Equal(permits, semaphore.CurrentCount); // no permit leaked at quiescence + } + + [Fact] + public async Task WaitAsync_WhenPermitsAvailable_CompletesSynchronouslyAndTracksCount() + { + var semaphore = new PrioritySemaphore(2); + Assert.Equal(2, semaphore.CurrentCount); + + var first = semaphore.WaitAsync(ResolutionPriority.Bulk, CancellationToken.None); + Assert.True(first.IsCompletedSuccessfully); + Assert.Equal(1, semaphore.CurrentCount); + + await semaphore.WaitAsync(ResolutionPriority.FirstScreenful, CancellationToken.None); + Assert.Equal(0, semaphore.CurrentCount); + + semaphore.Release(); + semaphore.Release(); + Assert.Equal(2, semaphore.CurrentCount); + } +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs index 5e3e4c67d..8b9a8fccc 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/EventLog/EffectsTests.cs @@ -1887,6 +1887,187 @@ public async Task HandleOpenLog_ResolverThrows_CallsReportCritical_DoesNotPropag mockDispatcher.DidNotReceive().Dispatch(Arg.Any()); } + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_DispatchesExactlyOneEagerPartialBeforeFinal() + { + const int total = 250; + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader(BuildReverseBatches(total, batchSize: 30), newestBookmark: "NEWEST")); + + var (openLog, dispatcher, _) = CreateEagerLoadEffects(fakeFactory); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + // The in-memory load finishes well under the 3-second partial timer, so the eager dispatch is the only + // partial - asserting the eager path fires exactly once. + var partials = AllPartialActions(dispatcher); + Assert.Single(partials); + Assert.NotEmpty(partials[0].Events); + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_EmptyLog_SeedsWatcherWithNullBookmark() + { + var fakeFactory = new FakeEventLogReaderFactory(new FakeEventLogReader([], newestBookmark: null)); + + var (openLog, dispatcher, watcher) = CreateEagerLoadEffects(fakeFactory); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + watcher.Received(1).AddLog(Constants.LogNameApplication, null, Arg.Any()); + Assert.Empty(SingleFinalEvents(dispatcher)); + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_FinalListContainsEveryEventOnceSortedDescending() + { + // 250 > the eager first-paint threshold (200), so the eager dispatch fires. + const int total = 250; + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader(BuildReverseBatches(total, batchSize: 30), newestBookmark: "NEWEST")); + + // Delay the newest batch so older batches resolve and AddRange first, forcing the completion-order scramble + // that the final sort must correct. + var (openLog, dispatcher, _) = CreateEagerLoadEffects( + fakeFactory, + resolveDelayMs: recordId => recordId > total - 30 ? 15 : 0); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + var finalIds = SingleFinalEvents(dispatcher).Select(resolved => resolved.RecordId).ToList(); + Assert.Equal(total, finalIds.Count); + Assert.Equal(total, finalIds.Distinct().Count()); + Assert.Equal( + Enumerable.Range(1, total).Select(id => (long?)id).OrderByDescending(id => id).ToList(), + finalIds); + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_PartialDeltasAreDisjointAndSubsetOfFinal() + { + const int total = 250; + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader(BuildReverseBatches(total, batchSize: 30), newestBookmark: "NEWEST")); + + var (openLog, dispatcher, _) = CreateEagerLoadEffects(fakeFactory); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + var partialIds = AllPartialEvents(dispatcher).Select(resolved => resolved.RecordId).ToList(); + Assert.NotEmpty(partialIds); + Assert.Equal(partialIds.Count, partialIds.Distinct().Count()); + + var finalIds = SingleFinalEvents(dispatcher).Select(resolved => resolved.RecordId).ToHashSet(); + Assert.All(partialIds, id => Assert.Contains(id, finalIds)); + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_PartialDeltasAreSortedNewestFirstDespiteCompletionOrder() + { + const int total = 250; // > eager threshold (200) so a partial dispatches mid-load + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader(BuildReverseBatches(total, batchSize: 30), newestBookmark: "NEWEST")); + + // Delay a second-newest batch so it resolves after older batches, scrambling completion order WITHIN the + // eager partial. Without the partial sort the dispatched delta would be out of newest-first order, breaking + // the raw-store index-0-is-newest invariant during load. + var (openLog, dispatcher, _) = CreateEagerLoadEffects( + fakeFactory, + resolveDelayMs: recordId => recordId is >= 191 and <= 220 ? 30 : 0); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + var partials = dispatcher.ReceivedCalls() + .Select(call => call.GetArguments()[0]) + .OfType() + .ToList(); + + Assert.NotEmpty(partials); + + foreach (var partial in partials) + { + var ids = partial.Events.Select(resolved => resolved.RecordId).ToList(); + Assert.Equal(ids.OrderByDescending(id => id).ToList(), ids); + } + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_PartialDeltasStayNewestFirstAcrossDeltasDespiteLateNewestBatch() + { + const int Total = 250; + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader(BuildReverseBatches(Total, batchSize: 30), newestBookmark: "NEWEST")); + + var (openLog, dispatcher, _) = CreateEagerLoadEffects( + fakeFactory, + resolveDelayMs: recordId => recordId > Total - 30 ? 25 : 0); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + var partialIds = AllPartialEvents(dispatcher).Select(resolved => resolved.RecordId).ToList(); + Assert.NotEmpty(partialIds); + + // Strictly newest-first across every delta (no cross-delta inversion). + Assert.Equal(partialIds.OrderByDescending(id => id).ToList(), partialIds); + + var finalIds = SingleFinalEvents(dispatcher).Select(resolved => resolved.RecordId).ToList(); + Assert.Equal(finalIds.Take(partialIds.Count).ToList(), partialIds); + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_SeedsWatcherFromNewestBookmark() + { + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader(BuildReverseBatches(50, batchSize: 30), newestBookmark: "NEWEST_BOOKMARK")); + + var (openLog, dispatcher, watcher) = CreateEagerLoadEffects(fakeFactory); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + // Under a reverse read the watcher must resume from the newest event; the reader exposes only NewestBookmark. + watcher.Received(1).AddLog(Constants.LogNameApplication, "NEWEST_BOOKMARK", Arg.Any()); + + // Lock the activation contract: the load path requested a reverse (newest-first) read. + Assert.True(fakeFactory.ReverseDirectionRequested); + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_WhenReaderInvalid_SurfacesLoadFailureNotEmptyLog() + { + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader([], newestBookmark: null) { IsValid = false, OpenErrorCode = 5 }); + + var (openLog, dispatcher, watcher) = CreateEagerLoadEffects(fakeFactory); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + // A log that fails to open surfaces an error instead of a misleading empty final load, and never seeds the watcher. + dispatcher.Received().Dispatch(Arg.Is(a => + a.ResolverStatus.Contains("Error") && a.ResolverStatus.Contains(Constants.LogNameApplication))); + Assert.Empty(dispatcher.ReceivedCalls().Select(call => call.GetArguments()[0]).OfType()); + watcher.DidNotReceive().AddLog(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task HandleOpenLog_ReverseEagerLoad_WhenReadStopsOnError_SurfacesLoadFailureNotFinalLoad() + { + const int total = 60; + var fakeFactory = new FakeEventLogReaderFactory( + new FakeEventLogReader(BuildReverseBatches(total, batchSize: 30), newestBookmark: "NEWEST") + { + LastErrorCode = 5 // a non-null error once the batches run out: a failed read, not a clean end-of-results + }); + + var (openLog, dispatcher, _) = CreateEagerLoadEffects(fakeFactory); + + await openLog.HandleOpenLog(new OpenLogAction(Constants.LogNameApplication, LogPathType.Channel), dispatcher); + + // A read that stops on a Win32 error is surfaced as a failure, not dispatched as a successful final load. + dispatcher.Received().Dispatch(Arg.Is(a => + a.ResolverStatus.Contains("Error") && a.ResolverStatus.Contains(Constants.LogNameApplication))); + Assert.Empty(dispatcher.ReceivedCalls().Select(call => call.GetArguments()[0]).OfType()); + } + [Fact] public async Task HandleOpenLog_ShouldThreadOpenLogsIdIntoDispatchedAddTableAction() { @@ -2265,6 +2446,15 @@ public void ReopenAfterDatabaseRemoval_DispatchesOpenLogPerSnapshotEntry() a.LogName == Constants.LogNameLog2 && a.LogPathType == LogPathType.File)); } + private static List AllPartialActions(IDispatcher dispatcher) => + dispatcher.ReceivedCalls() + .Select(call => call.GetArguments()[0]) + .OfType() + .ToList(); + + private static IEnumerable AllPartialEvents(IDispatcher dispatcher) => + AllPartialActions(dispatcher).SelectMany(partial => partial.Events); + private static EffectsHarness BuildHarness( IState eventLogState, IState rawEventStore, @@ -2307,7 +2497,8 @@ private static EffectsHarness BuildHarness( criticalErrorService, closeCoordinator, concurrencyState, - coordinator); + coordinator, + new EventLogReaderFactory()); var logReload = new LogReloadEffects( eventLogState, @@ -2350,6 +2541,93 @@ private static (ImmutableDictionary OpenLogs, IState BuildReverseBatches(int total, int batchSize) + { + var batches = new List(); + + // Newest first (descending RecordId), mirroring a reverse read. + for (int start = total; start >= 1; start -= batchSize) + { + int count = Math.Min(batchSize, start); + var batch = new EventRecord[count]; + + for (int offset = 0; offset < count; offset++) + { + batch[offset] = new EventRecord { RecordId = start - offset }; + } + + batches.Add(batch); + } + + return batches; + } + + private static (OpenLogEffects openLog, IDispatcher dispatcher, ILogWatcherService watcher) CreateEagerLoadEffects( + IEventLogReaderFactory readerFactory, + Func? resolveDelayMs = null) + { + var logData = new EventLogData(Constants.LogNameApplication, LogPathType.Channel); + + var openLogs = ImmutableDictionary.Empty + .SetItem(Constants.LogNameApplication, new OpenLogInfo(logData.Id, logData.Type)); + + var eventLogState = Substitute.For>(); + eventLogState.Value.Returns(new EventLogState + { + OpenLogs = openLogs, + AppliedFilter = new Filter(null, []) + }); + + var resolver = Substitute.For(); + resolver.ResolveEvent(Arg.Any()).Returns(callInfo => + { + var record = callInfo.Arg(); + + if (resolveDelayMs is not null) + { + int delay = resolveDelayMs(record.RecordId); + + if (delay > 0) { Thread.Sleep(delay); } + } + + return FilterEventBuilder.CreateTestEvent(recordId: record.RecordId); + }); + + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IEventResolver)).Returns(resolver); + + var serviceScope = Substitute.For(); + serviceScope.ServiceProvider.Returns(serviceProvider); + + var serviceScopeFactory = Substitute.For(); + serviceScopeFactory.CreateScope().Returns(serviceScope); + + var databaseService = Substitute.For(); + databaseService.InitialClassificationTask.Returns(Task.CompletedTask); + + var watcher = Substitute.For(); + watcher.RemoveLogAsync(Arg.Any()).Returns(Task.CompletedTask); + watcher.RemoveAllAsync().Returns(Task.CompletedTask); + + var dispatcher = Substitute.For(); + + var openLog = new OpenLogEffects( + eventLogState, + Substitute.For(), + watcher, + Substitute.For(), + Substitute.For(), + serviceScopeFactory, + databaseService, + Substitute.For(), + new LogCloseCoordinator(), + new EventLogConcurrencyState(), + new PartialLoadCoordinator(dispatcher, Timeout.InfiniteTimeSpan), + readerFactory); + + return (openLog, dispatcher, watcher); + } + private static (EffectsHarness effects, IDispatcher mockDispatcher) CreateEffects( bool continuouslyUpdate = false, ImmutableDictionary? activeLogs = null, @@ -2604,6 +2882,13 @@ private static IState EmptyRawStore() return rawStore; } + private static IReadOnlyList SingleFinalEvents(IDispatcher dispatcher) => + dispatcher.ReceivedCalls() + .Select(call => call.GetArguments()[0]) + .OfType() + .Single() + .Events; + // Wrapper that bundles the post-split effects classes together with their // shared singletons so existing tests keep their `effects.HandleXxx(...)` // call shape. Each method delegates to the appropriate split class. @@ -2652,4 +2937,46 @@ public Task HandleOpenLog(OpenLogAction action, IDispatcher dispatcher) => public Task HandleSetContinuouslyUpdate(SetContinuouslyUpdateAction action, IDispatcher dispatcher) => Filtering.HandleSetContinuouslyUpdate(action, dispatcher); } + + private sealed class FakeEventLogReader(IReadOnlyList batches, string? newestBookmark) + : IEventLogReader + { + private int _index; + + public bool IsValid { get; init; } = true; + + public int? LastErrorCode { get; init; } + + public string? NewestBookmark { get; } = newestBookmark; + + public int? OpenErrorCode { get; init; } + + public void Dispose() { } + + public bool TryGetEvents(out EventRecord[] events, int batchSize = 30) + { + if (_index >= batches.Count) + { + events = []; + + return false; + } + + events = batches[_index++]; + + return true; + } + } + + private sealed class FakeEventLogReaderFactory(IEventLogReader reader) : IEventLogReaderFactory + { + public bool ReverseDirectionRequested { get; private set; } + + public IEventLogReader CreateReader(string path, LogPathType pathType, bool renderXml = false, bool reverseDirection = false) + { + ReverseDirectionRequested = reverseDirection; + + return reader; + } + } } diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/TestUtils/Constants/Constants.GitHubRelease.cs b/tests/Unit/EventLogExpert.Runtime.Tests/TestUtils/Constants/Constants.GitHubRelease.cs index e4ef88b0b..e50c30d93 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/TestUtils/Constants/Constants.GitHubRelease.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/TestUtils/Constants/Constants.GitHubRelease.cs @@ -7,14 +7,14 @@ public sealed partial class Constants { public const string AppInstalledVersion = "23.1.1.1"; - public const string GitHubLatestName = "EventLogExpert_23.1.1.2_x64.msix"; + public const string GitHubLatestName = "EventLogExpert_23.1.1.2.msixbundle"; public const string GitHubLatestUri = - "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/EventLogExpert_23.1.1.2_x64.msix"; + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/EventLogExpert_23.1.1.2.msixbundle"; public const string GitHubLatestVersion = "v23.1.1.2"; - public const string GitHubPrereleaseName = "EventLogExpert_23.1.1.3_x64.msix"; + public const string GitHubPrereleaseName = "EventLogExpert_23.1.1.3.msixbundle"; public const string GitHubPrereleaseUri = - "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.3/EventLogExpert_23.1.1.3_x64.msix"; + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.3/EventLogExpert_23.1.1.3.msixbundle"; public const string GitHubPrereleaseVersion = "v23.1.1.3"; public const string GitHubReleaseNotes = diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceAssetSelectionTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceAssetSelectionTests.cs new file mode 100644 index 000000000..2d26b725d --- /dev/null +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceAssetSelectionTests.cs @@ -0,0 +1,189 @@ +// // Copyright (c) Microsoft Corporation. +// // Licensed under the MIT License. + +using EventLogExpert.Runtime.Update; + +namespace EventLogExpert.Runtime.Tests.Update; + +public sealed class UpdateServiceAssetSelectionTests +{ + private const string Arm64Name = "EventLogExpert_23.1.1.2_arm64.msix"; + private const string Arm64Uri = + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/EventLogExpert_23.1.1.2_arm64.msix"; + + private const string BundleName = "EventLogExpert_23.1.1.2.msixbundle"; + private const string BundleUri = + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/EventLogExpert_23.1.1.2.msixbundle"; + + private const string DotBundleName = "EventLogExpert.msixbundle"; + private const string DotBundleUri = + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/EventLogExpert.msixbundle"; + + private const string RuntimeName = "Microsoft.WindowsAppRuntime.1.7.msix"; + private const string RuntimeUri = + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/Microsoft.WindowsAppRuntime.1.7.msix"; + + private const string ToolsBundleName = "EventLogExpertTools_23.1.1.2.msixbundle"; + private const string ToolsBundleUri = + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/EventLogExpertTools_23.1.1.2.msixbundle"; + + private const string X64Name = "EventLogExpert_23.1.1.2_x64.msix"; + private const string X64Uri = + "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/EventLogExpert_23.1.1.2_x64.msix"; + + [Fact] + public void SelectUpdateDownloadUri_BundleAmidFullReleaseAssetSet_ReturnsBundleUri() + { + List assets = + [ + Asset(BundleName, BundleUri), + Asset(X64Name, X64Uri), + Asset(Arm64Name, Arm64Uri), + Asset(RuntimeName, RuntimeUri) + ]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Equal(BundleUri, result); + } + + [Fact] + public void SelectUpdateDownloadUri_BundleOnly_ReturnsBundleUri() + { + List assets = [Asset(BundleName, BundleUri)]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Equal(BundleUri, result); + } + + [Fact] + public void SelectUpdateDownloadUri_DifferentlyCasedBundleName_MatchesCaseInsensitively() + { + List assets = [Asset("eventlogexpert_23.1.1.2.MSIXBUNDLE", BundleUri)]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Equal(BundleUri, result); + } + + [Fact] + public void SelectUpdateDownloadUri_DotFormBundleName_ReturnsBundleUri() + { + List assets = + [ + Asset(DotBundleName, DotBundleUri), + Asset(X64Name, X64Uri), + Asset(Arm64Name, Arm64Uri) + ]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Equal(DotBundleUri, result); + } + + [Fact] + public void SelectUpdateDownloadUri_EmptyAssets_ReturnsNull() + { + string? result = UpdateService.SelectUpdateDownloadUri([]); + + Assert.Null(result); + } + + [Fact] + public void SelectUpdateDownloadUri_MalformedNullNameAssetWithValidBundle_ReturnsBundleUriWithoutThrowing() + { + List assets = + [ + new GitHubReleaseAsset { Name = null!, Uri = "https://malformed" }, + Asset(BundleName, BundleUri) + ]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Equal(BundleUri, result); + } + + [Fact] + public void SelectUpdateDownloadUri_NoBundleOnlyPerArchAndRuntime_ReturnsNull() + { + List assets = + [ + Asset(X64Name, X64Uri), + Asset(Arm64Name, Arm64Uri), + Asset(RuntimeName, RuntimeUri) + ]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Null(result); + } + + [Fact] + public void SelectUpdateDownloadUri_NullAssets_ReturnsNull() + { + string? result = UpdateService.SelectUpdateDownloadUri(null); + + Assert.Null(result); + } + + [Fact] + public void SelectUpdateDownloadUri_RuntimeAssetOnly_ReturnsNull() + { + List assets = [Asset(RuntimeName, RuntimeUri)]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Null(result); + } + + [Fact] + public void SelectUpdateDownloadUri_SiblingToolsBundleBeforeAppBundle_ReturnsAppBundleUri() + { + List assets = + [ + Asset(ToolsBundleName, ToolsBundleUri), + Asset(BundleName, BundleUri) + ]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Equal(BundleUri, result); + } + + [Fact] + public void SelectUpdateDownloadUri_SiblingToolsBundleOnly_ReturnsNull() + { + List assets = [Asset(ToolsBundleName, ToolsBundleUri)]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Null(result); + } + + [Fact] + public void SelectUpdateDownloadUri_WhitespaceUriBundleBeforeValidBundle_ReturnsValidBundleUri() + { + List assets = + [ + Asset(BundleName, " "), + Asset(BundleName, BundleUri) + ]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Equal(BundleUri, result); + } + + [Fact] + public void SelectUpdateDownloadUri_WhitespaceUriBundleOnly_ReturnsNull() + { + List assets = [Asset(BundleName, " ")]; + + string? result = UpdateService.SelectUpdateDownloadUri(assets); + + Assert.Null(result); + } + + private static GitHubReleaseAsset Asset(string name, string uri) => new() { Name = name, Uri = uri }; +} diff --git a/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs b/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs index fa8bb4466..d2f52d26f 100644 --- a/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs +++ b/tests/Unit/EventLogExpert.Runtime.Tests/Update/UpdateServiceTests.cs @@ -361,6 +361,64 @@ public async Task CheckForUpdates_ManualThenAutoScan_ShouldRunBoth() await mockGitHubService.Received(2).GetReleases(); } + [Fact] + public async Task CheckForUpdates_NoCompatiblePackageAutoScan_ShouldStaySilentAndNotDeploy() + { + // Arrange + var mockCurrentVersionProvider = Substitute.For(); + mockCurrentVersionProvider.CurrentVersion.Returns(new Version(Constants.AppInstalledVersion)); + mockCurrentVersionProvider.IsDevBuild.Returns(false); + + var mockGitHubService = Substitute.For(); + mockGitHubService.GetReleases().Returns(Task.FromResult(CreateRuntimeOnlyRelease())); + + var mockDeploymentService = Substitute.For(); + var mockAlertDialogService = Substitute.For(); + + var updateService = CreateUpdateService( + mockCurrentVersionProvider, + gitHubService: mockGitHubService, + deploymentService: mockDeploymentService, + alertDialogService: mockAlertDialogService); + + // Act + await updateService.CheckForUpdates(usePreRelease: false, userInitiated: false); + + // Assert + await mockAlertDialogService.DidNotReceive().ShowAlert(Arg.Any(), Arg.Any(), Arg.Any()); + mockDeploymentService.DidNotReceive().RestartNowAndUpdate(Arg.Any(), Arg.Any()); + mockDeploymentService.DidNotReceive().UpdateOnNextRestart(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task CheckForUpdates_NoCompatiblePackageUserInitiated_ShouldAlertAndNotDeploy() + { + // Arrange + var mockCurrentVersionProvider = Substitute.For(); + mockCurrentVersionProvider.CurrentVersion.Returns(new Version(Constants.AppInstalledVersion)); + mockCurrentVersionProvider.IsDevBuild.Returns(false); + + var mockGitHubService = Substitute.For(); + mockGitHubService.GetReleases().Returns(Task.FromResult(CreateRuntimeOnlyRelease())); + + var mockDeploymentService = Substitute.For(); + var mockAlertDialogService = Substitute.For(); + + var updateService = CreateUpdateService( + mockCurrentVersionProvider, + gitHubService: mockGitHubService, + deploymentService: mockDeploymentService, + alertDialogService: mockAlertDialogService); + + // Act + await updateService.CheckForUpdates(usePreRelease: false, userInitiated: true); + + // Assert + await mockAlertDialogService.Received(1).ShowAlert("Update Unavailable", Arg.Any(), "OK"); + mockDeploymentService.DidNotReceive().RestartNowAndUpdate(Arg.Any(), Arg.Any()); + mockDeploymentService.DidNotReceive().UpdateOnNextRestart(Arg.Any(), Arg.Any()); + } + [Fact] public async Task CheckForUpdates_NoReleases_ShouldShowAlert() { @@ -661,7 +719,7 @@ public async Task CheckForUpdates_WhenMalformedReleaseVersion_ShouldSkipAndLogWa } [Fact] - public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelPreviouslyEnabledThenDisabled_ShouldPromptRollback() + public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelNeverEnabled_ShouldEnableChannelAndSuppressRollbackPrompt() { // Arrange var mockCurrentVersionProvider = Substitute.For(); @@ -672,17 +730,19 @@ public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelPreviouslyEnabl mockGitHubService.GetReleases().Returns(Task.FromResult(GitHubUtils.CreateGitHubReleases())); var mockAppTitleService = Substitute.For(); + var mockAlertDialogService = Substitute.For(); var mockDeploymentService = Substitute.For(); var mockSettings = Substitute.For(); mockSettings.IsPreReleaseEnabled.Returns(false); - mockSettings.HasEverEnabledPreRelease.Returns(true); + mockSettings.HasEverEnabledPreRelease.Returns(false); var updateService = CreateUpdateService( mockCurrentVersionProvider, appTitleService: mockAppTitleService, gitHubService: mockGitHubService, deploymentService: mockDeploymentService, + alertDialogService: mockAlertDialogService, settings: mockSettings); // Act @@ -690,13 +750,17 @@ public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelPreviouslyEnabl // Assert mockAppTitleService.Received(1).SetIsPrerelease(true); - mockSettings.DidNotReceive().IsPreReleaseEnabled = Arg.Any(); + mockSettings.Received(1).IsPreReleaseEnabled = true; - mockDeploymentService.Received(1).UpdateOnNextRestart(Constants.GitHubLatestUri); + await mockAlertDialogService.DidNotReceive() + .ShowAlert("Update Available", Arg.Any(), Arg.Any(), Arg.Any()); + + mockDeploymentService.DidNotReceive().UpdateOnNextRestart(Arg.Any(), Arg.Any()); + mockDeploymentService.DidNotReceive().RestartNowAndUpdate(Arg.Any(), Arg.Any()); } [Fact] - public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelNeverEnabled_ShouldEnableChannelAndSuppressRollbackPrompt() + public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelPreviouslyEnabledThenDisabled_ShouldPromptRollback() { // Arrange var mockCurrentVersionProvider = Substitute.For(); @@ -707,19 +771,17 @@ public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelNeverEnabled_Sh mockGitHubService.GetReleases().Returns(Task.FromResult(GitHubUtils.CreateGitHubReleases())); var mockAppTitleService = Substitute.For(); - var mockAlertDialogService = Substitute.For(); var mockDeploymentService = Substitute.For(); var mockSettings = Substitute.For(); mockSettings.IsPreReleaseEnabled.Returns(false); - mockSettings.HasEverEnabledPreRelease.Returns(false); + mockSettings.HasEverEnabledPreRelease.Returns(true); var updateService = CreateUpdateService( mockCurrentVersionProvider, appTitleService: mockAppTitleService, gitHubService: mockGitHubService, deploymentService: mockDeploymentService, - alertDialogService: mockAlertDialogService, settings: mockSettings); // Act @@ -727,13 +789,9 @@ public async Task CheckForUpdates_WhenRunningPreReleaseAndChannelNeverEnabled_Sh // Assert mockAppTitleService.Received(1).SetIsPrerelease(true); - mockSettings.Received(1).IsPreReleaseEnabled = true; - - await mockAlertDialogService.DidNotReceive() - .ShowAlert("Update Available", Arg.Any(), Arg.Any(), Arg.Any()); + mockSettings.DidNotReceive().IsPreReleaseEnabled = Arg.Any(); - mockDeploymentService.DidNotReceive().UpdateOnNextRestart(Arg.Any(), Arg.Any()); - mockDeploymentService.DidNotReceive().RestartNowAndUpdate(Arg.Any(), Arg.Any()); + mockDeploymentService.Received(1).UpdateOnNextRestart(Constants.GitHubLatestUri); } [Fact] @@ -854,6 +912,27 @@ await mockAlertDialogService.DidNotReceive() .ShowAlert(Arg.Any(), Arg.Any(), Arg.Any()); } + // A release newer than the installed version whose only asset is the WindowsAppRuntime dependency (no + // EventLogExpert package or bundle), so the bundle-only selector finds no compatible package. + private static IEnumerable CreateRuntimeOnlyRelease() => + [ + new() + { + Version = "v23.1.1.2", + IsPreRelease = false, + ReleaseDate = DateTime.Now, + Assets = + [ + new GitHubReleaseAsset + { + Name = "Microsoft.WindowsAppRuntime.1.7.msix", + Uri = "https://github.com/microsoft/EventLogExpert/releases/download/v23.1.1.2/Microsoft.WindowsAppRuntime.1.7.msix" + } + ], + RawChanges = string.Empty + } + ]; + private static UpdateService CreateUpdateService( ICurrentVersionProvider? currentVersionProvider = null, IAppTitleService? appTitleService = null,