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)); + } + } +}