Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 63 additions & 0 deletions docs/Rules/ERP043.md
Original file line number Diff line number Diff line change
@@ -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 `<string value=""/>` 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)
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// A fixer for an empty <c>Message</c> on an <c>[Event]</c> attribute reported by <see cref="EmptyEventMessageAnalyzer"/>.
/// </summary>
[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";

/// <inheritdoc />
public override ImmutableArray<string> FixableDiagnosticIds => ImmutableArray.Create(EmptyEventMessageAnalyzer.Rule.Id);

/// <inheritdoc />
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;

/// <inheritdoc />
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<AttributeArgumentSyntax>();

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<Document> 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<AttributeArgumentSyntax>();
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<Document> 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<AttributeArgumentSyntax>();
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading
Loading