From 7989d511f538ed4758ce9f697901f692f8b74289 Mon Sep 17 00:00:00 2001 From: Sergey Teplyakov Date: Sat, 5 Sep 2026 18:45:08 -0700 Subject: [PATCH] Add ERP043 for empty EventSource messages Preserve the saved EventSource work in an isolated draft and detect empty event messages before they produce invalid ETW manifest entries. Provide targeted code fixes without changing ERP042 behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ReadMe.md | 1 + docs/Rules/ERP043.md | 63 ++++++ .../EmptyEventMessageCodeFixProvider.cs | 123 ++++++++++++ .../EmptyEventMessageAnalyzerTests.cs | 175 +++++++++++++++++ .../EmptyEventMessageCodeFixProviderTests.cs | 182 ++++++++++++++++++ .../CoreAnalyzers/EventSourceAnalyzerTests.cs | 13 ++ .../AnalyzerReleases.Unshipped.md | 1 + .../DiagnosticDescriptors.cs | 10 + .../EmptyEventMessageAnalyzer.cs | 83 ++++++++ 9 files changed, 651 insertions(+) create mode 100644 docs/Rules/ERP043.md create mode 100644 src/ErrorProne.NET.CoreAnalyzers.CodeFixes/EventSourceAnalysis/EmptyEventMessageCodeFixProvider.cs create mode 100644 src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageAnalyzerTests.cs create mode 100644 src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageCodeFixProviderTests.cs create mode 100644 src/ErrorProne.NET.CoreAnalyzers/EventSourceAnalysis/EmptyEventMessageAnalyzer.cs diff --git a/ReadMe.md b/ReadMe.md index 56daeda..a938596 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -40,6 +40,7 @@ Add the following nuget package to you project: https://www.nuget.org/packages/E | [EPC30](https://github.com/SergeyTeplyakov/ErrorProne.NET/tree/master/docs/Rules/EPC30.md) | Method calls itself recursively | | [ERP041](https://github.com/SergeyTeplyakov/ErrorProne.NET/tree/master/docs/Rules/ERP041.md) | EventSource class should be sealed | | [ERP042](https://github.com/SergeyTeplyakov/ErrorProne.NET/tree/master/docs/Rules/ERP042.md) | EventSource implementation is not correct | +| [ERP043](https://github.com/SergeyTeplyakov/ErrorProne.NET/tree/master/docs/Rules/ERP043.md) | EventSource event has an empty Message | ### Concurrency diff --git a/docs/Rules/ERP043.md b/docs/Rules/ERP043.md new file mode 100644 index 0000000..276d92d --- /dev/null +++ b/docs/Rules/ERP043.md @@ -0,0 +1,63 @@ +# ERP043 - EventSource event has an empty Message + +This analyzer warns when an `[Event(...)]` attribute on an `EventSource`-derived class sets its +`Message` property to the **empty string**. + +## Code that triggers the analyzer + +```csharp +using System.Diagnostics.Tracing; + +[EventSource(Name = "MyEventSource")] +public sealed class MyEventSource : EventSource +{ + // ❌ ERP043 - empty Message + [Event(1, Message = "")] + public void AppStarted(string name) => WriteEvent(1, name); +} +``` + +## Why an empty message is a bug + +`EventAttribute.Message` is documented as an *optional* format string (it uses `{0}`, `{1}` payload +substitution). Setting it to `""` makes the runtime emit an empty `` entry in the ETW +manifest string table. + +On **Windows Server 2025** the `TdhLoadManifest()` API no longer tolerates such entries. Older Windows +silently appended a space to empty strings so they passed manifest validation; Windows Server 2025 removed +that behavior, so loading the manifest now fails. A **single** bad entry drops the **entire** manifest — the +provider can no longer be subscribed and **all** of its events stop flowing, with no error surfaced to the +process. Windows Server 2019/2022 are not affected. + +`EventAttribute.Message` has no documented rule forbidding the empty string, and the runtime does not throw, +so this undocumented-but-real constraint is exactly the kind of thing an analyzer should encode. Because +.NET Framework will not fix this in the runtime, a compile-time analyzer is the most reliable guard. + +## When it triggers + +- **Only** on a compile-time constant empty string (`Message = ""`, or a `const string` equal to `""`). +- `Message = " "` (single space), any non-empty message, `null`, and a missing `Message` are all fine. +- Only for types deriving directly or indirectly from `System.Diagnostics.Tracing.EventSource`. + +## How to fix + +Two code fixes are offered: + +Both fixes also work for empty constant expressions and support Fix All. They change only the +attribute argument, not the declaration of a referenced constant. + +1. **Remove Message argument** (preferred): + ```csharp + [Event(1, Level = EventLevel.Error, Message = "")] // before + [Event(1, Level = EventLevel.Error)] // after + ``` +2. **Use a single space**: + ```csharp + [Event(1, Message = "")] // before + [Event(1, Message = " ")] // after + ``` + +## See also + +- [ERP042 - EventSource implementation is not correct](ERP042.md) +- [`EventAttribute.Message`](https://learn.microsoft.com/dotnet/api/system.diagnostics.tracing.eventattribute.message) diff --git a/src/ErrorProne.NET.CoreAnalyzers.CodeFixes/EventSourceAnalysis/EmptyEventMessageCodeFixProvider.cs b/src/ErrorProne.NET.CoreAnalyzers.CodeFixes/EventSourceAnalysis/EmptyEventMessageCodeFixProvider.cs new file mode 100644 index 0000000..e58c039 --- /dev/null +++ b/src/ErrorProne.NET.CoreAnalyzers.CodeFixes/EventSourceAnalysis/EmptyEventMessageCodeFixProvider.cs @@ -0,0 +1,123 @@ +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace ErrorProne.NET.EventSourceAnalysis +{ + /// + /// A fixer for an empty Message on an [Event] attribute reported by . + /// + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(EmptyEventMessageCodeFixProvider)), Shared] + public class EmptyEventMessageCodeFixProvider : CodeFixProvider + { + public const string RemoveMessageTitle = "Remove Message argument"; + public const string UseSingleSpaceTitle = "Use a single space"; + + /// + public override ImmutableArray FixableDiagnosticIds => ImmutableArray.Create(EmptyEventMessageAnalyzer.Rule.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + foreach (var diagnostic in context.Diagnostics) + { + var messageArg = root.FindNode(diagnostic.Location.SourceSpan) + .FirstAncestorOrSelf(); + + if (!IsEmptyMessageArgument(messageArg, semanticModel, context.CancellationToken)) + { + continue; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: RemoveMessageTitle, + createChangedDocument: c => RemoveMessageArgumentAsync(context.Document, diagnostic.Location, c), + equivalenceKey: RemoveMessageTitle), + diagnostic); + + context.RegisterCodeFix( + CodeAction.Create( + title: UseSingleSpaceTitle, + createChangedDocument: c => UseSingleSpaceAsync(context.Document, diagnostic.Location, c), + equivalenceKey: UseSingleSpaceTitle), + diagnostic); + } + } + + private static bool IsEmptyMessageArgument( + AttributeArgumentSyntax? argument, SemanticModel semanticModel, CancellationToken cancellationToken) + { + if (argument?.NameEquals?.Name.Identifier.ValueText != "Message") + { + return false; + } + + var constant = semanticModel.GetConstantValue(argument.Expression, cancellationToken); + return constant.HasValue && constant.Value is string message && message.Length == 0; + } + + private static async Task RemoveMessageArgumentAsync(Document document, Location location, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var messageArg = root.FindNode(location.SourceSpan).FirstAncestorOrSelf(); + if (messageArg is null) + { + return document; + } + + // RemoveNode handles the associated comma separator so we don't leave a dangling/leading comma. + var newRoot = root.RemoveNode(messageArg, SyntaxRemoveOptions.KeepNoTrivia); + return newRoot is null ? document : document.WithSyntaxRoot(newRoot); + } + + private static async Task UseSingleSpaceAsync(Document document, Location location, CancellationToken cancellationToken) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + { + return document; + } + + var messageArg = root.FindNode(location.SourceSpan).FirstAncestorOrSelf(); + if (messageArg is null) + { + return document; + } + + var newLiteral = SyntaxFactory.LiteralExpression( + SyntaxKind.StringLiteralExpression, + SyntaxFactory.Literal(" ")) + .WithTriviaFrom(messageArg.Expression); + + var newRoot = root.ReplaceNode(messageArg.Expression, newLiteral); + return document.WithSyntaxRoot(newRoot); + } + } +} diff --git a/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageAnalyzerTests.cs b/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageAnalyzerTests.cs new file mode 100644 index 0000000..f3e2ff9 --- /dev/null +++ b/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageAnalyzerTests.cs @@ -0,0 +1,175 @@ +using NUnit.Framework; +using System.Threading.Tasks; +using VerifyCS = ErrorProne.NET.TestHelpers.CSharpCodeFixVerifier< + ErrorProne.NET.EventSourceAnalysis.EmptyEventMessageAnalyzer, + Microsoft.CodeAnalysis.Testing.EmptyCodeFixProvider>; + +namespace ErrorProne.NET.CoreAnalyzers.Tests.AsyncAnalyzers +{ + [TestFixture] + public class EmptyEventMessageAnalyzerTests + { + [Test] + public async Task Warn_On_Indirect_EventSource_Subclass() + { + string code = @" +public abstract class BaseEventSource : System.Diagnostics.Tracing.EventSource { } + +public sealed class DemoEventSource : BaseEventSource +{ + [System.Diagnostics.Tracing.Event(1, [|Message = """"|])] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task No_Warn_On_Null_Message() + { + string code = @" +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Message = null)] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task No_Warn_On_Unrelated_Event_Attribute() + { + string code = @" +public class EventAttribute : System.Attribute +{ + public EventAttribute(int id) { } + public string Message { get; set; } +} + +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [Event(1, Message = """")] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task Warn_On_Empty_Message() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, [|Message = """"|])] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task Warn_On_Empty_Message_With_Other_Named_Args() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Level = System.Diagnostics.Tracing.EventLevel.Error, [|Message = """"|])] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task Warn_On_Empty_Message_From_Const() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + private const string EmptyConst = """"; + + [System.Diagnostics.Tracing.Event(1, [|Message = EmptyConst|])] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task No_Warn_On_Missing_Message() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1)] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task No_Warn_On_Single_Space_Message() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Message = "" "")] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task No_Warn_On_Non_Empty_Message() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Message = ""Started {0}"")] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task No_Warn_On_Non_Empty_Const_Message() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + private const string Msg = ""Started""; + + [System.Diagnostics.Tracing.Event(1, Message = Msg)] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + + [Test] + public async Task No_Warn_On_Empty_Message_In_Non_EventSource_Class() + { + // The diagnostic must only fire for types deriving from EventSource. + string code = @" +public sealed class NotAnEventSource +{ + [System.Diagnostics.Tracing.Event(1, Message = """")] + public void AppStarted(string m) {} +}"; + + await VerifyCS.VerifyAsync(code); + } + } +} diff --git a/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageCodeFixProviderTests.cs b/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageCodeFixProviderTests.cs new file mode 100644 index 0000000..be59d7b --- /dev/null +++ b/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EmptyEventMessageCodeFixProviderTests.cs @@ -0,0 +1,182 @@ +using NUnit.Framework; +using System.Threading.Tasks; +using ErrorProne.NET.EventSourceAnalysis; +using ErrorProne.NET.TestHelpers; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Testing; +using VerifyCS = ErrorProne.NET.TestHelpers.CSharpCodeFixVerifier< + ErrorProne.NET.EventSourceAnalysis.EmptyEventMessageAnalyzer, + ErrorProne.NET.EventSourceAnalysis.EmptyEventMessageCodeFixProvider>; + +namespace ErrorProne.NET.CoreAnalyzers.Tests.AsyncAnalyzers +{ + [TestFixture] + public class EmptyEventMessageCodeFixProviderTests + { + private static async Task VerifyFixAsync(string code, string fixedCode, string equivalenceKey) + { + var test = new VerifyCS.Test + { + TestCode = code, + FixedCode = fixedCode, + LanguageVersion = LanguageVersion.Latest, + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + CodeActionEquivalenceKey = equivalenceKey, + }; + + await test.WithoutGeneratedCodeVerification().RunAsync(); + } + + [Test] + public async Task Fix_Empty_Constant_Expressions( + [Values("EmptyConst", "(EmptyConst)", "EmptyConst + \"\"", "$\"{EmptyConst}\"")] string expression, + [Values("Message", "@Message", "\\u004dessage")] string argumentName, + [Values(EmptyEventMessageCodeFixProvider.RemoveMessageTitle, EmptyEventMessageCodeFixProvider.UseSingleSpaceTitle)] string equivalenceKey) + { + string code = $@" +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{{ + private const string EmptyConst = """"; + + [System.Diagnostics.Tracing.Event(1, [|{argumentName} = {expression}|])] + public void AppStarted(string m) => WriteEvent(1, m); +}}"; + + var fixedArgument = equivalenceKey == EmptyEventMessageCodeFixProvider.RemoveMessageTitle + ? "" + : $", {argumentName} = \" \""; + string expected = $@" +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{{ + private const string EmptyConst = """"; + + [System.Diagnostics.Tracing.Event(1{fixedArgument})] + public void AppStarted(string m) => WriteEvent(1, m); +}}"; + + await VerifyFixAsync(code, expected, equivalenceKey); + } + + [Test] + public async Task Fix_Multiple_Events( + [Values(EmptyEventMessageCodeFixProvider.RemoveMessageTitle, EmptyEventMessageCodeFixProvider.UseSingleSpaceTitle)] string equivalenceKey) + { + string code = @" +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + private const string EmptyConst = """"; + + [System.Diagnostics.Tracing.Event(1, [|Message = EmptyConst|])] + public void AppStarted(string m) => WriteEvent(1, m); + + [System.Diagnostics.Tracing.Event(2, [|Message = """"|])] + public void AppStopped(string m) => WriteEvent(2, m); +}"; + + var fixedArgument = equivalenceKey == EmptyEventMessageCodeFixProvider.RemoveMessageTitle + ? "" + : ", Message = \" \""; + string expected = $@" +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{{ + private const string EmptyConst = """"; + + [System.Diagnostics.Tracing.Event(1{fixedArgument})] + public void AppStarted(string m) => WriteEvent(1, m); + + [System.Diagnostics.Tracing.Event(2{fixedArgument})] + public void AppStopped(string m) => WriteEvent(2, m); +}}"; + + await VerifyFixAsync(code, expected, equivalenceKey); + } + + [Test] + public async Task Remove_Message_Argument_When_Last() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Level = System.Diagnostics.Tracing.EventLevel.Error, [|Message = """"|])] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + string expected = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Level = System.Diagnostics.Tracing.EventLevel.Error)] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyFixAsync(code, expected, EmptyEventMessageCodeFixProvider.RemoveMessageTitle); + } + + [Test] + public async Task Remove_Message_Argument_When_Only_Named_Arg() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, [|Message = """"|])] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + string expected = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1)] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyFixAsync(code, expected, EmptyEventMessageCodeFixProvider.RemoveMessageTitle); + } + + [Test] + public async Task Remove_Message_Argument_When_Middle() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, [|Message = """"|], Level = System.Diagnostics.Tracing.EventLevel.Error)] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + string expected = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Level = System.Diagnostics.Tracing.EventLevel.Error)] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyFixAsync(code, expected, EmptyEventMessageCodeFixProvider.RemoveMessageTitle); + } + + [Test] + public async Task Use_Single_Space() + { + string code = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, [|Message = """"|])] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + string expected = @" +[System.Diagnostics.Tracing.EventSource(Name = ""Demo"")] +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Message = "" "")] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyFixAsync(code, expected, EmptyEventMessageCodeFixProvider.UseSingleSpaceTitle); + } + } +} diff --git a/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EventSourceAnalyzerTests.cs b/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EventSourceAnalyzerTests.cs index 1141b2d..0262fa0 100644 --- a/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EventSourceAnalyzerTests.cs +++ b/src/ErrorProne.NET.CoreAnalyzers.Tests/CoreAnalyzers/EventSourceAnalyzerTests.cs @@ -9,6 +9,19 @@ namespace ErrorProne.NET.CoreAnalyzers.Tests.AsyncAnalyzers [TestFixture] public class EventSourceAnalyzerTests { + [Test] + public async Task Empty_Message_Is_Not_Reported_As_ERP042() + { + string code = @" +public sealed class DemoEventSource : System.Diagnostics.Tracing.EventSource +{ + [System.Diagnostics.Tracing.Event(1, Message = """")] + public void AppStarted(string m) => WriteEvent(1, m); +}"; + + await VerifyCS.VerifyAsync(code); + } + [Test] public async Task Warn_On_Id_Mismatch() { diff --git a/src/ErrorProne.NET.CoreAnalyzers/AnalyzerReleases.Unshipped.md b/src/ErrorProne.NET.CoreAnalyzers/AnalyzerReleases.Unshipped.md index 6826c2b..36e5b56 100644 --- a/src/ErrorProne.NET.CoreAnalyzers/AnalyzerReleases.Unshipped.md +++ b/src/ErrorProne.NET.CoreAnalyzers/AnalyzerReleases.Unshipped.md @@ -39,3 +39,4 @@ ERP022 | ErrorHandling | Warning | SwallowAllExceptionsAnalyzer ERP031 | Concurrency | Warning | ConcurrentCollectionAnalyzer ERP041 | CodeSmell | Info | EventSourceSealedAnalyzer ERP042 | CodeSmell | Warning | EventSourceAnalyzer +ERP043 | CodeSmell | Warning | EmptyEventMessageAnalyzer diff --git a/src/ErrorProne.NET.CoreAnalyzers/DiagnosticDescriptors.cs b/src/ErrorProne.NET.CoreAnalyzers/DiagnosticDescriptors.cs index cf56702..56e755f 100644 --- a/src/ErrorProne.NET.CoreAnalyzers/DiagnosticDescriptors.cs +++ b/src/ErrorProne.NET.CoreAnalyzers/DiagnosticDescriptors.cs @@ -279,6 +279,16 @@ internal static class DiagnosticDescriptors description: "The event source implementation must follow special rules to avoid hitting runtime errors.", helpLinkUri: GetHelpUri(nameof(ERP042))); + /// + public static readonly DiagnosticDescriptor ERP043 = new DiagnosticDescriptor( + nameof(ERP043), + title: "EventSource event has an empty Message", + messageFormat: "{0}: {1}", + CodeSmellCategory, DiagnosticSeverity.Warning, isEnabledByDefault: true, + description: "An empty [Event(Message = \"\")] produces an invalid ETW manifest that fails to load " + + "on Windows Server 2025, silently dropping the whole provider.", + helpLinkUri: GetHelpUri(nameof(ERP043))); + /// public static readonly DiagnosticDescriptor EPC35 = new DiagnosticDescriptor( "EPC35", diff --git a/src/ErrorProne.NET.CoreAnalyzers/EventSourceAnalysis/EmptyEventMessageAnalyzer.cs b/src/ErrorProne.NET.CoreAnalyzers/EventSourceAnalysis/EmptyEventMessageAnalyzer.cs new file mode 100644 index 0000000..af30a74 --- /dev/null +++ b/src/ErrorProne.NET.CoreAnalyzers/EventSourceAnalysis/EmptyEventMessageAnalyzer.cs @@ -0,0 +1,83 @@ +using System.Diagnostics.Tracing; +using System.Linq; +using ErrorProne.NET.Core; +using ErrorProne.NET.CoreAnalyzers; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace ErrorProne.NET.EventSourceAnalysis +{ + /// + /// An analyzer that warns when an [Event(...)] attribute sets Message to an empty string. + /// + /// + /// An empty Message makes the runtime emit an empty ETW manifest string-table entry + /// (<string value=""/>). On Windows Server 2025 the TdhLoadManifest() API no longer + /// tolerates such entries, so loading the manifest fails and the entire provider is silently dropped. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class EmptyEventMessageAnalyzer : DiagnosticAnalyzerBase + { + /// + public static DiagnosticDescriptor Rule => DiagnosticDescriptors.ERP043; + + /// + public EmptyEventMessageAnalyzer() + : base(Rule) + { + } + + /// + protected override void InitializeCore(AnalysisContext context) + { + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); + } + + private void AnalyzeMethod(SymbolAnalysisContext context) + { + var method = (IMethodSymbol)context.Symbol; + if (!method.ContainingType.BaseType.EnumerateBaseTypesAndSelf() + .Any(type => type.IsClrType(context.Compilation, typeof(EventSource)))) + { + return; + } + + var eventAttribute = method.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.IsClrType(context.Compilation, typeof(EventAttribute)) == true); + if (eventAttribute is null) + { + return; + } + + var messageArg = eventAttribute.NamedArguments.FirstOrDefault(kvp => kvp.Key == "Message"); + + // Only trigger on a compile-time constant empty string. A missing 'Message', a single space, + // or any non-empty message is fine. + if (messageArg.Key != "Message" || + messageArg.Value.Value is not string message || + message.Length != 0) + { + return; + } + + // Prefer reporting on the 'Message = ""' argument syntax so the fixer can target just that argument. + var attributeSyntax = eventAttribute.ApplicationSyntaxReference?.GetSyntax(context.CancellationToken) as AttributeSyntax; + var messageArgSyntax = attributeSyntax?.ArgumentList?.Arguments + .FirstOrDefault(arg => arg.NameEquals?.Name.Identifier.ValueText == "Message"); + + var location = messageArgSyntax?.GetLocation() + ?? method.TryGetDeclarationSyntax()?.Identifier.GetLocation(); + if (location is null) + { + return; + } + + var error = + $"Event {method.Name} has an empty Message. An empty Message emits an empty ETW manifest " + + "string-table entry that fails to load on Windows Server 2025 and drops the entire provider. " + + "Remove the Message argument or set it to a single space."; + context.ReportDiagnostic(Diagnostic.Create(Rule, location, method.ContainingType.Name, error)); + } + } +}