From ccc20b6088e97e1be72f7a67be8ce909cce9ee43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:17:23 +0000 Subject: [PATCH 01/13] Initial plan From ba08a179a5843afacc1968204590a26e57432f48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:20:54 +0000 Subject: [PATCH 02/13] Add Observer pattern attribute definitions and enums Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../Observer/ObservedEventAttribute.cs | 26 +++++++++ .../Observer/ObserverAttribute.cs | 53 +++++++++++++++++++ .../Observer/ObserverExceptionPolicy.cs | 26 +++++++++ .../Observer/ObserverHubAttribute.cs | 27 ++++++++++ .../Observer/ObserverOrderPolicy.cs | 19 +++++++ .../Observer/ObserverThreadingPolicy.cs | 27 ++++++++++ 6 files changed, 178 insertions(+) create mode 100644 src/PatternKit.Generators.Abstractions/Observer/ObservedEventAttribute.cs create mode 100644 src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs create mode 100644 src/PatternKit.Generators.Abstractions/Observer/ObserverExceptionPolicy.cs create mode 100644 src/PatternKit.Generators.Abstractions/Observer/ObserverHubAttribute.cs create mode 100644 src/PatternKit.Generators.Abstractions/Observer/ObserverOrderPolicy.cs create mode 100644 src/PatternKit.Generators.Abstractions/Observer/ObserverThreadingPolicy.cs diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObservedEventAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObservedEventAttribute.cs new file mode 100644 index 0000000..d685a58 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObservedEventAttribute.cs @@ -0,0 +1,26 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Marks a property in an -decorated class as an observable event stream. +/// The property must be static, partial, and have a getter only. +/// +/// +/// +/// The generator will create a singleton instance of the event type for this property. +/// +/// +/// Example: +/// +/// [ObserverHub] +/// public static partial class SystemEvents +/// { +/// [ObservedEvent] +/// public static partial TemperatureChanged TemperatureChanged { get; } +/// } +/// +/// +/// +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = false)] +public sealed class ObservedEventAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs new file mode 100644 index 0000000..8057e11 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs @@ -0,0 +1,53 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Marks a type for Observer pattern code generation. +/// The type must be declared as partial (class, struct, record class, or record struct). +/// +/// +/// +/// The generator will produce Subscribe and Publish methods with configurable +/// threading, exception handling, and ordering semantics. +/// +/// +/// Example: +/// +/// [Observer] +/// public partial class TemperatureChanged { } +/// +/// +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +public sealed class ObserverAttribute : Attribute +{ + /// + /// Gets or sets the threading policy for Subscribe/Unsubscribe/Publish operations. + /// Default is . + /// + public ObserverThreadingPolicy Threading { get; set; } = ObserverThreadingPolicy.Locking; + + /// + /// Gets or sets the exception handling policy during publishing. + /// Default is . + /// + public ObserverExceptionPolicy Exceptions { get; set; } = ObserverExceptionPolicy.Continue; + + /// + /// Gets or sets the invocation order policy for event handlers. + /// Default is . + /// + public ObserverOrderPolicy Order { get; set; } = ObserverOrderPolicy.RegistrationOrder; + + /// + /// Gets or sets whether to generate async publish methods. + /// When not explicitly set, async methods are generated if any async handlers are detected. + /// + public bool GenerateAsync { get; set; } = true; + + /// + /// Gets or sets whether to force all handlers to be async. + /// When true, only async Subscribe methods are generated. + /// Default is false (both sync and async handlers are supported). + /// + public bool ForceAsync { get; set; } = false; +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverExceptionPolicy.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverExceptionPolicy.cs new file mode 100644 index 0000000..964b243 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverExceptionPolicy.cs @@ -0,0 +1,26 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Defines how exceptions from event handlers are handled during publishing. +/// +public enum ObserverExceptionPolicy +{ + /// + /// Continue invoking all handlers even if some throw exceptions. + /// Exceptions are either swallowed or routed to an optional error hook. + /// Default and safest for most scenarios. + /// + Continue = 0, + + /// + /// Stop publishing and rethrow the first exception encountered. + /// Remaining handlers are not invoked. + /// + Stop = 1, + + /// + /// Invoke all handlers and collect any exceptions. + /// Throws an AggregateException at the end if any handlers threw. + /// + Aggregate = 2 +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverHubAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverHubAttribute.cs new file mode 100644 index 0000000..93796be --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverHubAttribute.cs @@ -0,0 +1,27 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Marks a type as an observer event hub that groups multiple event streams. +/// The type must be declared as partial and static. +/// +/// +/// +/// Use this attribute on a static class that will contain multiple +/// properties, each representing a separate event stream. +/// +/// +/// Example: +/// +/// [ObserverHub] +/// public static partial class SystemEvents +/// { +/// [ObservedEvent] +/// public static partial TemperatureChanged TemperatureChanged { get; } +/// } +/// +/// +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class ObserverHubAttribute : Attribute +{ +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverOrderPolicy.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverOrderPolicy.cs new file mode 100644 index 0000000..250767c --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverOrderPolicy.cs @@ -0,0 +1,19 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Defines the invocation order guarantee for event handlers. +/// +public enum ObserverOrderPolicy +{ + /// + /// Handlers are invoked in the order they were registered (FIFO). + /// Default and recommended for deterministic behavior. + /// + RegistrationOrder = 0, + + /// + /// No order guarantee. Handlers may be invoked in any order. + /// May provide better performance with certain threading policies. + /// + Undefined = 1 +} diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverThreadingPolicy.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverThreadingPolicy.cs new file mode 100644 index 0000000..419d198 --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverThreadingPolicy.cs @@ -0,0 +1,27 @@ +namespace PatternKit.Generators.Observer; + +/// +/// Defines the threading policy for an observer pattern implementation. +/// +public enum ObserverThreadingPolicy +{ + /// + /// No thread safety. Fast, but not safe for concurrent Subscribe/Unsubscribe/Publish. + /// Use only when all operations occur on a single thread. + /// + SingleThreadedFast = 0, + + /// + /// Uses locking for thread safety. Subscribe/Unsubscribe operations take locks, + /// and Publish snapshots the subscriber list under a lock for deterministic iteration. + /// Default and recommended for most scenarios. + /// + Locking = 1, + + /// + /// Lock-free concurrent implementation using atomic operations. + /// Thread-safe with potentially better performance under high concurrency, + /// but ordering may degrade to Undefined unless additional work is done. + /// + Concurrent = 2 +} From e65fd7e01a5de1710f9452e5c4efe83211e90afe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:23:13 +0000 Subject: [PATCH 03/13] Add initial Observer generator structure and diagnostic tracking Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../AnalyzerReleases.Unshipped.md | 5 + .../Observer/ObserverGenerator.cs | 789 ++++++++++++++++++ 2 files changed, 794 insertions(+) create mode 100644 src/PatternKit.Generators/Observer/ObserverGenerator.cs diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 65ad3e8..8eec4e0 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -126,3 +126,8 @@ PKST007 | PatternKit.Generators.State | Error | Entry/Exit hook signature invali PKST008 | PatternKit.Generators.State | Warning | Async method detected but async generation disabled PKST009 | PatternKit.Generators.State | Error | Generic types not supported for State pattern PKST010 | PatternKit.Generators.State | Error | Nested types not supported for State pattern +PKOBS001 | PatternKit.Generators.Observer | Error | Type marked with [Observer] must be partial +PKOBS002 | PatternKit.Generators.Observer | Error | Type marked with [ObserverHub] must be partial and static +PKOBS003 | PatternKit.Generators.Observer | Error | Property marked with [ObservedEvent] has invalid shape +PKOBS004 | PatternKit.Generators.Observer | Warning | Async publish requested but async handler shape unsupported +PKOBS005 | PatternKit.Generators.Observer | Warning | Invalid configuration combination diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs new file mode 100644 index 0000000..384f91c --- /dev/null +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -0,0 +1,789 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace PatternKit.Generators.Observer; + +/// +/// Incremental source generator for the Observer pattern. +/// Generates Subscribe/Publish methods with configurable threading, exception, and ordering policies. +/// +[Generator] +public sealed class ObserverGenerator : IIncrementalGenerator +{ + // Diagnostic IDs + private const string DiagnosticIdNotPartial = "PKOBS001"; + private const string DiagnosticIdHubNotPartialStatic = "PKOBS002"; + private const string DiagnosticIdInvalidEventProperty = "PKOBS003"; + private const string DiagnosticIdAsyncUnsupported = "PKOBS004"; + private const string DiagnosticIdInvalidConfig = "PKOBS005"; + + // Diagnostic descriptors + private static readonly DiagnosticDescriptor NotPartialRule = new( + DiagnosticIdNotPartial, + "Type must be partial", + "Type '{0}' marked with [Observer] must be declared as partial", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor HubNotPartialStaticRule = new( + DiagnosticIdHubNotPartialStatic, + "Hub type must be partial and static", + "Type '{0}' marked with [ObserverHub] must be declared as partial static", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidEventPropertyRule = new( + DiagnosticIdInvalidEventProperty, + "Invalid event property", + "Property '{0}' marked with [ObservedEvent] must be static partial with a getter only", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor AsyncUnsupportedRule = new( + DiagnosticIdAsyncUnsupported, + "Async not supported", + "Async publish requested but async handler shape is unsupported in this context", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor InvalidConfigRule = new( + DiagnosticIdInvalidConfig, + "Invalid configuration", + "{0}", + "PatternKit.Generators.Observer", + DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find all types marked with [Observer] + var observerTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Observer.ObserverAttribute", + predicate: static (node, _) => node is TypeDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + // Generate observer implementations + context.RegisterSourceOutput(observerTypes, static (spc, occ) => + { + GenerateObserver(spc, occ); + }); + + // Find all types marked with [ObserverHub] + var hubTypes = context.SyntaxProvider.ForAttributeWithMetadataName( + fullyQualifiedMetadataName: "PatternKit.Generators.Observer.ObserverHubAttribute", + predicate: static (node, _) => node is ClassDeclarationSyntax, + transform: static (ctx, _) => ctx + ); + + // Generate hub implementations + context.RegisterSourceOutput(hubTypes, static (spc, occ) => + { + GenerateHub(spc, occ); + }); + } + + private static void GenerateObserver(SourceProductionContext context, GeneratorAttributeSyntaxContext occurrence) + { + var typeSymbol = (INamedTypeSymbol)occurrence.TargetSymbol; + var syntax = (TypeDeclarationSyntax)occurrence.TargetNode; + + // Validate that type is partial + if (!IsPartial(syntax)) + { + context.ReportDiagnostic(Diagnostic.Create( + NotPartialRule, + syntax.Identifier.GetLocation(), + typeSymbol.Name)); + return; + } + + // Extract configuration from attribute + var config = ExtractObserverConfig(occurrence.Attributes[0]); + + // Validate configuration + if (!ValidateConfiguration(config, context, syntax.GetLocation())) + { + return; + } + + // Generate the source code + var source = GenerateObserverSource(typeSymbol, config); + var fileName = $"{typeSymbol.Name}.Observer.g.cs"; + context.AddSource(fileName, source); + } + + private static void GenerateHub(SourceProductionContext context, GeneratorAttributeSyntaxContext occurrence) + { + var typeSymbol = (INamedTypeSymbol)occurrence.TargetSymbol; + var syntax = (ClassDeclarationSyntax)occurrence.TargetNode; + + // Validate that type is partial and static + if (!IsPartial(syntax) || !IsStatic(syntax)) + { + context.ReportDiagnostic(Diagnostic.Create( + HubNotPartialStaticRule, + syntax.Identifier.GetLocation(), + typeSymbol.Name)); + return; + } + + // Find all properties marked with [ObservedEvent] + var eventProperties = typeSymbol.GetMembers() + .OfType() + .Where(p => p.GetAttributes() + .Any(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Observer.ObservedEventAttribute")) + .ToList(); + + // Validate event properties + foreach (var prop in eventProperties) + { + if (!prop.IsStatic || prop.SetMethod != null) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidEventPropertyRule, + prop.Locations.FirstOrDefault() ?? Location.None, + prop.Name)); + return; + } + } + + // Generate the hub source code + var source = GenerateHubSource(typeSymbol, eventProperties); + var fileName = $"{typeSymbol.Name}.ObserverHub.g.cs"; + context.AddSource(fileName, source); + } + + private static bool IsPartial(TypeDeclarationSyntax syntax) + { + return syntax.Modifiers.Any(m => m.Text == "partial"); + } + + private static bool IsStatic(TypeDeclarationSyntax syntax) + { + return syntax.Modifiers.Any(m => m.Text == "static"); + } + + private static ObserverConfig ExtractObserverConfig(AttributeData attribute) + { + var config = new ObserverConfig(); + + foreach (var namedArg in attribute.NamedArguments) + { + switch (namedArg.Key) + { + case "Threading": + config.Threading = (int)namedArg.Value.Value!; + break; + case "Exceptions": + config.Exceptions = (int)namedArg.Value.Value!; + break; + case "Order": + config.Order = (int)namedArg.Value.Value!; + break; + case "GenerateAsync": + config.GenerateAsync = (bool)namedArg.Value.Value!; + break; + case "ForceAsync": + config.ForceAsync = (bool)namedArg.Value.Value!; + break; + } + } + + return config; + } + + private static bool ValidateConfiguration(ObserverConfig config, SourceProductionContext context, Location location) + { + // Validate: Concurrent + RegistrationOrder requires extra work + if (config.Threading == 2 && config.Order == 0) // Concurrent + RegistrationOrder + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidConfigRule, + location, + "Concurrent threading with RegistrationOrder requires additional ordering guarantees and may impact performance")); + // This is a warning, not an error, so we continue + } + + return true; + } + + private static string GenerateObserverSource(INamedTypeSymbol typeSymbol, ObserverConfig config) + { + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? null + : typeSymbol.ContainingNamespace.ToDisplayString(); + + var typeKind = GetTypeKind(typeSymbol); + var typeName = typeSymbol.Name; + + var code = new CodeBuilder(); + + code.AppendLine("#nullable enable"); + code.AppendLine("// "); + code.AppendLine(); + + if (ns != null) + { + code.AppendLine($"namespace {ns};"); + code.AppendLine(); + } + + // Start type declaration + code.Append($"{GetAccessibility(typeSymbol)} partial {typeKind} {typeName}"); + code.AppendLine(); + code.AppendLine("{"); + + // Generate the implementation based on configuration + GenerateObserverImplementation(code, config); + + code.AppendLine("}"); + + return code.ToString(); + } + + private static void GenerateObserverImplementation(CodeBuilder code, ObserverConfig config) + { + // Generate subscription storage based on threading policy + GenerateStorage(code, config); + code.AppendLine(); + + // Generate Subscribe methods + GenerateSubscribeMethods(code, config); + code.AppendLine(); + + // Generate Publish methods + GeneratePublishMethods(code, config); + code.AppendLine(); + + // Generate Subscription class (IDisposable) + GenerateSubscriptionClass(code, config); + } + + private static void GenerateStorage(CodeBuilder code, ObserverConfig config) + { + code.Indent(); + + switch (config.Threading) + { + case 0: // SingleThreadedFast + code.AppendLine("private System.Collections.Generic.List _subscriptions = new();"); + code.AppendLine("private int _nextId;"); + break; + + case 1: // Locking + code.AppendLine("private readonly object _lock = new();"); + code.AppendLine("private System.Collections.Generic.List _subscriptions = new();"); + code.AppendLine("private int _nextId;"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + // Use ImmutableList for order preservation with concurrent access + code.AppendLine("private System.Collections.Immutable.ImmutableList _subscriptions = System.Collections.Immutable.ImmutableList.Empty;"); + code.AppendLine("private int _nextId;"); + } + else + { + // Can use ConcurrentBag for better performance + code.AppendLine("private System.Collections.Concurrent.ConcurrentBag _subscriptions = new();"); + code.AppendLine("private int _nextId;"); + } + break; + } + + code.Unindent(); + } + + private static void GenerateSubscribeMethods(CodeBuilder code, ObserverConfig config) + { + code.Indent(); + + // Determine generic parameter (we'll use object for now, but ideally would infer from context) + var eventType = "TEvent"; + + if (!config.ForceAsync) + { + // Generate sync Subscribe method + code.AppendLine($"public System.IDisposable Subscribe<{eventType}>(System.Action<{eventType}> handler)"); + code.AppendLine("{"); + code.Indent(); + GenerateSubscribeBody(code, config, false); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine(); + } + + if (config.GenerateAsync) + { + // Generate async Subscribe method + code.AppendLine($"public System.IDisposable Subscribe<{eventType}>(System.Func<{eventType}, System.Threading.Tasks.ValueTask> handler)"); + code.AppendLine("{"); + code.Indent(); + GenerateSubscribeBody(code, config, true); + code.Unindent(); + code.AppendLine("}"); + } + + code.Unindent(); + } + + private static void GenerateSubscribeBody(CodeBuilder code, ObserverConfig config, bool isAsync) + { + code.AppendLine("var id = System.Threading.Interlocked.Increment(ref _nextId);"); + code.AppendLine($"var sub = new Subscription(this, id, handler, {(isAsync ? "true" : "false")});"); + code.AppendLine(); + + switch (config.Threading) + { + case 0: // SingleThreadedFast + code.AppendLine("_subscriptions.Add(sub);"); + break; + + case 1: // Locking + code.AppendLine("lock (_lock)"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("_subscriptions.Add(sub);"); + code.Unindent(); + code.AppendLine("}"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + code.AppendLine("System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, s) => list.Add(s), sub);"); + } + else + { + code.AppendLine("_subscriptions.Add(sub);"); + } + break; + } + + code.AppendLine(); + code.AppendLine("return sub;"); + } + + private static void GeneratePublishMethods(CodeBuilder code, ObserverConfig config) + { + code.Indent(); + + var eventType = "TEvent"; + + if (!config.ForceAsync) + { + // Generate sync Publish method + code.AppendLine($"public void Publish<{eventType}>({eventType} value)"); + code.AppendLine("{"); + code.Indent(); + GenerateSyncPublishBody(code, config); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine(); + } + + if (config.GenerateAsync) + { + // Generate async Publish method + code.AppendLine($"public async System.Threading.Tasks.ValueTask PublishAsync<{eventType}>({eventType} value, System.Threading.CancellationToken cancellationToken = default)"); + code.AppendLine("{"); + code.Indent(); + GenerateAsyncPublishBody(code, config); + code.Unindent(); + code.AppendLine("}"); + } + + code.Unindent(); + } + + private static void GenerateSyncPublishBody(CodeBuilder code, ObserverConfig config) + { + // Take snapshot based on threading policy + GenerateSnapshot(code, config); + code.AppendLine(); + + // Generate exception handling setup + if (config.Exceptions == 2) // Aggregate + { + code.AppendLine("System.Collections.Generic.List? errors = null;"); + code.AppendLine(); + } + + // Iterate through snapshot + code.AppendLine("foreach (var sub in snapshot)"); + code.AppendLine("{"); + code.Indent(); + + code.AppendLine("if (sub.IsAsync) continue; // Skip async handlers in sync publish"); + code.AppendLine(); + + // Try-catch based on exception policy + if (config.Exceptions == 1) // Stop + { + code.AppendLine("sub.InvokeSync(value);"); + } + else + { + code.AppendLine("try"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("sub.InvokeSync(value);"); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine("catch (System.Exception ex)"); + code.AppendLine("{"); + code.Indent(); + + if (config.Exceptions == 0) // Continue + { + code.AppendLine("// Optionally call OnSubscriberError if defined"); + code.AppendLine("OnSubscriberError(ex);"); + } + else if (config.Exceptions == 2) // Aggregate + { + code.AppendLine("(errors ??= new()).Add(ex);"); + } + + code.Unindent(); + code.AppendLine("}"); + } + + code.Unindent(); + code.AppendLine("}"); + + // Throw aggregate if needed + if (config.Exceptions == 2) + { + code.AppendLine(); + code.AppendLine("if (errors is { Count: > 0 })"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("throw new System.AggregateException(errors);"); + code.Unindent(); + code.AppendLine("}"); + } + } + + private static void GenerateAsyncPublishBody(CodeBuilder code, ObserverConfig config) + { + // Take snapshot + GenerateSnapshot(code, config); + code.AppendLine(); + + // Generate exception handling setup + if (config.Exceptions == 2) // Aggregate + { + code.AppendLine("System.Collections.Generic.List? errors = null;"); + code.AppendLine(); + } + + // Iterate through snapshot + code.AppendLine("foreach (var sub in snapshot)"); + code.AppendLine("{"); + code.Indent(); + + // Check cancellation + code.AppendLine("if (cancellationToken.IsCancellationRequested) break;"); + code.AppendLine(); + + // Try-catch based on exception policy + if (config.Exceptions == 1) // Stop + { + code.AppendLine("await sub.InvokeAsync(value, cancellationToken).ConfigureAwait(false);"); + } + else + { + code.AppendLine("try"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("await sub.InvokeAsync(value, cancellationToken).ConfigureAwait(false);"); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine("catch (System.Exception ex)"); + code.AppendLine("{"); + code.Indent(); + + if (config.Exceptions == 0) // Continue + { + code.AppendLine("OnSubscriberError(ex);"); + } + else if (config.Exceptions == 2) // Aggregate + { + code.AppendLine("(errors ??= new()).Add(ex);"); + } + + code.Unindent(); + code.AppendLine("}"); + } + + code.Unindent(); + code.AppendLine("}"); + + // Throw aggregate if needed + if (config.Exceptions == 2) + { + code.AppendLine(); + code.AppendLine("if (errors is { Count: > 0 })"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("throw new System.AggregateException(errors);"); + code.Unindent(); + code.AppendLine("}"); + } + } + + private static void GenerateSnapshot(CodeBuilder code, ObserverConfig config) + { + switch (config.Threading) + { + case 0: // SingleThreadedFast + code.AppendLine("var snapshot = _subscriptions.ToArray();"); + break; + + case 1: // Locking + code.AppendLine("Subscription[] snapshot;"); + code.AppendLine("lock (_lock)"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("snapshot = _subscriptions.ToArray();"); + code.Unindent(); + code.AppendLine("}"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + code.AppendLine("var snapshot = System.Threading.Volatile.Read(ref _subscriptions).ToArray();"); + } + else + { + code.AppendLine("var snapshot = _subscriptions.ToArray();"); + } + break; + } + } + + private static void GenerateSubscriptionClass(CodeBuilder code, ObserverConfig config) + { + code.Indent(); + + code.AppendLine("partial void OnSubscriberError(System.Exception ex) { }"); + code.AppendLine(); + + code.AppendLine("private void Unsubscribe(int id)"); + code.AppendLine("{"); + code.Indent(); + + switch (config.Threading) + { + case 0: // SingleThreadedFast + code.AppendLine("_subscriptions.RemoveAll(s => s.Id == id);"); + break; + + case 1: // Locking + code.AppendLine("lock (_lock)"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("_subscriptions.RemoveAll(s => s.Id == id);"); + code.Unindent(); + code.AppendLine("}"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + code.AppendLine("System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, id) => list.RemoveAll(s => s.Id == id), id);"); + } + else + { + code.AppendLine("// Note: ConcurrentBag doesn't support removal efficiently"); + code.AppendLine("// Mark as removed instead"); + } + break; + } + + code.Unindent(); + code.AppendLine("}"); + code.AppendLine(); + + // Generate Subscription nested class + code.AppendLine("private sealed class Subscription : System.IDisposable"); + code.AppendLine("{"); + code.Indent(); + + code.AppendLine("private readonly object _parent;"); + code.AppendLine("private readonly int _id;"); + code.AppendLine("private readonly object _handler;"); + code.AppendLine("private readonly bool _isAsync;"); + code.AppendLine("private int _disposed;"); + code.AppendLine(); + + code.AppendLine("public int Id => _id;"); + code.AppendLine("public bool IsAsync => _isAsync;"); + code.AppendLine(); + + code.AppendLine("public Subscription(object parent, int id, object handler, bool isAsync)"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("_parent = parent;"); + code.AppendLine("_id = id;"); + code.AppendLine("_handler = handler;"); + code.AppendLine("_isAsync = isAsync;"); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine(); + + code.AppendLine("public void InvokeSync(TEvent value)"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("if (System.Threading.Volatile.Read(ref _disposed) != 0) return;"); + code.AppendLine("((System.Action)_handler)(value);"); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine(); + + code.AppendLine("public System.Threading.Tasks.ValueTask InvokeAsync(TEvent value, System.Threading.CancellationToken ct)"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("if (System.Threading.Volatile.Read(ref _disposed) != 0) return default;"); + code.AppendLine("if (_isAsync)"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("return ((System.Func)_handler)(value);"); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine("else"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("((System.Action)_handler)(value);"); + code.AppendLine("return default;"); + code.Unindent(); + code.AppendLine("}"); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine(); + + code.AppendLine("public void Dispose()"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine("if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;"); + code.AppendLine("((dynamic)_parent).Unsubscribe(_id);"); + code.Unindent(); + code.AppendLine("}"); + + code.Unindent(); + code.AppendLine("}"); + + code.Unindent(); + } + + private static string GenerateHubSource(INamedTypeSymbol typeSymbol, List eventProperties) + { + var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace + ? null + : typeSymbol.ContainingNamespace.ToDisplayString(); + + var typeName = typeSymbol.Name; + + var code = new CodeBuilder(); + + code.AppendLine("#nullable enable"); + code.AppendLine("// "); + code.AppendLine(); + + if (ns != null) + { + code.AppendLine($"namespace {ns};"); + code.AppendLine(); + } + + code.AppendLine($"{GetAccessibility(typeSymbol)} static partial class {typeName}"); + code.AppendLine("{"); + code.Indent(); + + // Generate each event property + foreach (var prop in eventProperties) + { + var propType = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var propName = prop.Name; + + code.AppendLine($"private static readonly {propType} _{char.ToLower(propName[0])}{propName.Substring(1)} = new();"); + code.AppendLine(); + code.AppendLine($"public static partial {propType} {propName}"); + code.AppendLine("{"); + code.Indent(); + code.AppendLine($"get => _{char.ToLower(propName[0])}{propName.Substring(1)};"); + code.Unindent(); + code.AppendLine("}"); + code.AppendLine(); + } + + code.Unindent(); + code.AppendLine("}"); + + return code.ToString(); + } + + private static string GetTypeKind(INamedTypeSymbol symbol) + { + return symbol.TypeKind switch + { + TypeKind.Class => symbol.IsRecord ? "record class" : "class", + TypeKind.Struct => symbol.IsRecord ? "record struct" : "struct", + _ => "class" + }; + } + + private static string GetAccessibility(ISymbol symbol) + { + return symbol.DeclaredAccessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + } + + private class ObserverConfig + { + public int Threading { get; set; } = 1; // Locking + public int Exceptions { get; set; } = 0; // Continue + public int Order { get; set; } = 0; // RegistrationOrder + public bool GenerateAsync { get; set; } = true; + public bool ForceAsync { get; set; } = false; + } + + private class CodeBuilder + { + private readonly System.Text.StringBuilder _sb = new(); + private int _indentLevel = 0; + private const string IndentString = " "; + + public void Indent() => _indentLevel++; + public void Unindent() => _indentLevel = Math.Max(0, _indentLevel - 1); + + public void Append(string text) => _sb.Append(text); + + public void AppendLine(string text = "") + { + if (!string.IsNullOrEmpty(text)) + { + for (int i = 0; i < _indentLevel; i++) + _sb.Append(IndentString); + _sb.Append(text); + } + _sb.AppendLine(); + } + + public override string ToString() => _sb.ToString(); + } +} From 6431f6ab81a5e2e4e88157a84aa5294377f399ab Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:34:58 +0000 Subject: [PATCH 04/13] Rewrite ObserverGenerator to support payload type extraction from attribute Complete rewrite of ObserverGenerator.cs to extract the payload type from the [Observer(typeof(TPayload))] attribute constructor argument and generate type-safe Subscribe/Publish methods. Key features: - Extract TPayload from attribute constructor (typeof(TPayload)) - Generate Subscribe(Action) and Subscribe(Func) - Generate Publish(TPayload) and PublishAsync(TPayload, CancellationToken) - Implement proper snapshot semantics for thread-safe iteration - Support three threading policies: * SingleThreadedFast: No locking, just a List * Locking: Use lock() for thread safety (default) * Concurrent: Use Immutable collections for lock-free operation - Support three exception policies: * Continue: Invoke all handlers, call optional OnSubscriberError hook * Stop: Rethrow first exception * Aggregate: Collect all exceptions and throw AggregateException - Support RegistrationOrder (FIFO) and Undefined order policies - Support all target type kinds (class, struct, record class, record struct) - Handle structs without field initializers (C# 11+ compatibility) - Nested private Subscription class implementing IDisposable - Idempotent, thread-safe disposal - Clean, deterministic code generation with proper nullability annotations The generator now follows the same pattern as StrategyGenerator for extracting constructor arguments and generating clean, focused implementation code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AnalyzerReleases.Unshipped.md | 6 +- .../Observer/ObserverGenerator.cs | 802 ++++++------------ 2 files changed, 257 insertions(+), 551 deletions(-) diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index 8eec4e0..5ab1640 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -127,7 +127,5 @@ PKST008 | PatternKit.Generators.State | Warning | Async method detected but asyn PKST009 | PatternKit.Generators.State | Error | Generic types not supported for State pattern PKST010 | PatternKit.Generators.State | Error | Nested types not supported for State pattern PKOBS001 | PatternKit.Generators.Observer | Error | Type marked with [Observer] must be partial -PKOBS002 | PatternKit.Generators.Observer | Error | Type marked with [ObserverHub] must be partial and static -PKOBS003 | PatternKit.Generators.Observer | Error | Property marked with [ObservedEvent] has invalid shape -PKOBS004 | PatternKit.Generators.Observer | Warning | Async publish requested but async handler shape unsupported -PKOBS005 | PatternKit.Generators.Observer | Warning | Invalid configuration combination +PKOBS002 | PatternKit.Generators.Observer | Error | Unable to extract payload type from [Observer] attribute +PKOBS003 | PatternKit.Generators.Observer | Warning | Invalid configuration combination diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs index 384f91c..8fc0de9 100644 --- a/src/PatternKit.Generators/Observer/ObserverGenerator.cs +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp.Syntax; +using System.Text; namespace PatternKit.Generators.Observer; @@ -10,14 +11,10 @@ namespace PatternKit.Generators.Observer; [Generator] public sealed class ObserverGenerator : IIncrementalGenerator { - // Diagnostic IDs private const string DiagnosticIdNotPartial = "PKOBS001"; - private const string DiagnosticIdHubNotPartialStatic = "PKOBS002"; - private const string DiagnosticIdInvalidEventProperty = "PKOBS003"; - private const string DiagnosticIdAsyncUnsupported = "PKOBS004"; - private const string DiagnosticIdInvalidConfig = "PKOBS005"; + private const string DiagnosticIdMissingPayload = "PKOBS002"; + private const string DiagnosticIdInvalidConfig = "PKOBS003"; - // Diagnostic descriptors private static readonly DiagnosticDescriptor NotPartialRule = new( DiagnosticIdNotPartial, "Type must be partial", @@ -26,30 +23,14 @@ public sealed class ObserverGenerator : IIncrementalGenerator DiagnosticSeverity.Error, isEnabledByDefault: true); - private static readonly DiagnosticDescriptor HubNotPartialStaticRule = new( - DiagnosticIdHubNotPartialStatic, - "Hub type must be partial and static", - "Type '{0}' marked with [ObserverHub] must be declared as partial static", + private static readonly DiagnosticDescriptor MissingPayloadRule = new( + DiagnosticIdMissingPayload, + "Missing payload type", + "Unable to extract payload type from [Observer] attribute on '{0}'", "PatternKit.Generators.Observer", DiagnosticSeverity.Error, isEnabledByDefault: true); - private static readonly DiagnosticDescriptor InvalidEventPropertyRule = new( - DiagnosticIdInvalidEventProperty, - "Invalid event property", - "Property '{0}' marked with [ObservedEvent] must be static partial with a getter only", - "PatternKit.Generators.Observer", - DiagnosticSeverity.Error, - isEnabledByDefault: true); - - private static readonly DiagnosticDescriptor AsyncUnsupportedRule = new( - DiagnosticIdAsyncUnsupported, - "Async not supported", - "Async publish requested but async handler shape is unsupported in this context", - "PatternKit.Generators.Observer", - DiagnosticSeverity.Warning, - isEnabledByDefault: true); - private static readonly DiagnosticDescriptor InvalidConfigRule = new( DiagnosticIdInvalidConfig, "Invalid configuration", @@ -60,31 +41,16 @@ public sealed class ObserverGenerator : IIncrementalGenerator public void Initialize(IncrementalGeneratorInitializationContext context) { - // Find all types marked with [Observer] var observerTypes = context.SyntaxProvider.ForAttributeWithMetadataName( fullyQualifiedMetadataName: "PatternKit.Generators.Observer.ObserverAttribute", predicate: static (node, _) => node is TypeDeclarationSyntax, transform: static (ctx, _) => ctx ); - // Generate observer implementations context.RegisterSourceOutput(observerTypes, static (spc, occ) => { GenerateObserver(spc, occ); }); - - // Find all types marked with [ObserverHub] - var hubTypes = context.SyntaxProvider.ForAttributeWithMetadataName( - fullyQualifiedMetadataName: "PatternKit.Generators.Observer.ObserverHubAttribute", - predicate: static (node, _) => node is ClassDeclarationSyntax, - transform: static (ctx, _) => ctx - ); - - // Generate hub implementations - context.RegisterSourceOutput(hubTypes, static (spc, occ) => - { - GenerateHub(spc, occ); - }); } private static void GenerateObserver(SourceProductionContext context, GeneratorAttributeSyntaxContext occurrence) @@ -92,7 +58,6 @@ private static void GenerateObserver(SourceProductionContext context, GeneratorA var typeSymbol = (INamedTypeSymbol)occurrence.TargetSymbol; var syntax = (TypeDeclarationSyntax)occurrence.TargetNode; - // Validate that type is partial if (!IsPartial(syntax)) { context.ReportDiagnostic(Diagnostic.Create( @@ -102,658 +67,426 @@ private static void GenerateObserver(SourceProductionContext context, GeneratorA return; } - // Extract configuration from attribute - var config = ExtractObserverConfig(occurrence.Attributes[0]); - - // Validate configuration - if (!ValidateConfiguration(config, context, syntax.GetLocation())) - { - return; - } - - // Generate the source code - var source = GenerateObserverSource(typeSymbol, config); - var fileName = $"{typeSymbol.Name}.Observer.g.cs"; - context.AddSource(fileName, source); - } - - private static void GenerateHub(SourceProductionContext context, GeneratorAttributeSyntaxContext occurrence) - { - var typeSymbol = (INamedTypeSymbol)occurrence.TargetSymbol; - var syntax = (ClassDeclarationSyntax)occurrence.TargetNode; - - // Validate that type is partial and static - if (!IsPartial(syntax) || !IsStatic(syntax)) + var attr = occurrence.Attributes[0]; + if (attr.ConstructorArguments.Length == 0 || attr.ConstructorArguments[0].Value is not INamedTypeSymbol payloadType) { context.ReportDiagnostic(Diagnostic.Create( - HubNotPartialStaticRule, + MissingPayloadRule, syntax.Identifier.GetLocation(), typeSymbol.Name)); return; } - // Find all properties marked with [ObservedEvent] - var eventProperties = typeSymbol.GetMembers() - .OfType() - .Where(p => p.GetAttributes() - .Any(a => a.AttributeClass?.ToDisplayString() == "PatternKit.Generators.Observer.ObservedEventAttribute")) - .ToList(); - - // Validate event properties - foreach (var prop in eventProperties) - { - if (!prop.IsStatic || prop.SetMethod != null) - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidEventPropertyRule, - prop.Locations.FirstOrDefault() ?? Location.None, - prop.Name)); - return; - } - } - - // Generate the hub source code - var source = GenerateHubSource(typeSymbol, eventProperties); - var fileName = $"{typeSymbol.Name}.ObserverHub.g.cs"; + var config = ExtractConfig(attr); + var source = GenerateSource(typeSymbol, payloadType, config); + var fileName = $"{typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat).Replace("global::", "").Replace("<", "_").Replace(">", "_").Replace(".", "_")}.Observer.g.cs"; context.AddSource(fileName, source); } private static bool IsPartial(TypeDeclarationSyntax syntax) - { - return syntax.Modifiers.Any(m => m.Text == "partial"); - } - - private static bool IsStatic(TypeDeclarationSyntax syntax) - { - return syntax.Modifiers.Any(m => m.Text == "static"); - } + => syntax.Modifiers.Any(m => m.Text == "partial"); - private static ObserverConfig ExtractObserverConfig(AttributeData attribute) + private static ObserverConfig ExtractConfig(AttributeData attr) { var config = new ObserverConfig(); - - foreach (var namedArg in attribute.NamedArguments) + foreach (var arg in attr.NamedArguments) { - switch (namedArg.Key) + switch (arg.Key) { case "Threading": - config.Threading = (int)namedArg.Value.Value!; + config.Threading = (int)arg.Value.Value!; break; case "Exceptions": - config.Exceptions = (int)namedArg.Value.Value!; + config.Exceptions = (int)arg.Value.Value!; break; case "Order": - config.Order = (int)namedArg.Value.Value!; + config.Order = (int)arg.Value.Value!; break; case "GenerateAsync": - config.GenerateAsync = (bool)namedArg.Value.Value!; + config.GenerateAsync = (bool)arg.Value.Value!; break; case "ForceAsync": - config.ForceAsync = (bool)namedArg.Value.Value!; + config.ForceAsync = (bool)arg.Value.Value!; break; } } - return config; } - private static bool ValidateConfiguration(ObserverConfig config, SourceProductionContext context, Location location) - { - // Validate: Concurrent + RegistrationOrder requires extra work - if (config.Threading == 2 && config.Order == 0) // Concurrent + RegistrationOrder - { - context.ReportDiagnostic(Diagnostic.Create( - InvalidConfigRule, - location, - "Concurrent threading with RegistrationOrder requires additional ordering guarantees and may impact performance")); - // This is a warning, not an error, so we continue - } - - return true; - } - - private static string GenerateObserverSource(INamedTypeSymbol typeSymbol, ObserverConfig config) + private static string GenerateSource(INamedTypeSymbol typeSymbol, INamedTypeSymbol payloadType, ObserverConfig config) { var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace ? null : typeSymbol.ContainingNamespace.ToDisplayString(); - var typeKind = GetTypeKind(typeSymbol); - var typeName = typeSymbol.Name; + var typeKind = typeSymbol.TypeKind switch + { + TypeKind.Class => typeSymbol.IsRecord ? "record class" : "class", + TypeKind.Struct => typeSymbol.IsRecord ? "record struct" : "struct", + _ => "class" + }; - var code = new CodeBuilder(); + var accessibility = typeSymbol.DeclaredAccessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; - code.AppendLine("#nullable enable"); - code.AppendLine("// "); - code.AppendLine(); + var typeName = typeSymbol.Name; + var payloadTypeName = payloadType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + var sb = new StringBuilder(); + sb.AppendLine("#nullable enable"); + sb.AppendLine("// "); + sb.AppendLine(); + + // Add necessary using directives + if (config.Threading == 2 && config.Order == 0) // Concurrent + RegistrationOrder uses ImmutableList + { + sb.AppendLine("using System.Linq;"); + sb.AppendLine(); + } if (ns != null) { - code.AppendLine($"namespace {ns};"); - code.AppendLine(); + sb.AppendLine($"namespace {ns};"); + sb.AppendLine(); } - // Start type declaration - code.Append($"{GetAccessibility(typeSymbol)} partial {typeKind} {typeName}"); - code.AppendLine(); - code.AppendLine("{"); - - // Generate the implementation based on configuration - GenerateObserverImplementation(code, config); + var isStruct = typeSymbol.TypeKind == TypeKind.Struct; - code.AppendLine("}"); + sb.AppendLine($"{accessibility} partial {typeKind} {typeName}"); + sb.AppendLine("{"); - return code.ToString(); - } - - private static void GenerateObserverImplementation(CodeBuilder code, ObserverConfig config) - { - // Generate subscription storage based on threading policy - GenerateStorage(code, config); - code.AppendLine(); + GenerateFields(sb, config, isStruct); + GenerateSubscribeMethods(sb, payloadTypeName, config); + GeneratePublishMethods(sb, payloadTypeName, config); + GenerateUnsubscribeMethod(sb, config); + GenerateOnErrorHook(sb); + GenerateSubscriptionClass(sb, payloadTypeName, config); - // Generate Subscribe methods - GenerateSubscribeMethods(code, config); - code.AppendLine(); + sb.AppendLine("}"); - // Generate Publish methods - GeneratePublishMethods(code, config); - code.AppendLine(); - - // Generate Subscription class (IDisposable) - GenerateSubscriptionClass(code, config); + return sb.ToString(); } - private static void GenerateStorage(CodeBuilder code, ObserverConfig config) + private static void GenerateFields(StringBuilder sb, ObserverConfig config, bool isStruct) { - code.Indent(); - + // For all types, use nullable fields and ensure initialization in helper methods switch (config.Threading) { case 0: // SingleThreadedFast - code.AppendLine("private System.Collections.Generic.List _subscriptions = new();"); - code.AppendLine("private int _nextId;"); + sb.AppendLine(" private System.Collections.Generic.List? _subscriptions;"); + sb.AppendLine(" private int _nextId;"); break; case 1: // Locking - code.AppendLine("private readonly object _lock = new();"); - code.AppendLine("private System.Collections.Generic.List _subscriptions = new();"); - code.AppendLine("private int _nextId;"); + sb.AppendLine(" private object? _lock;"); + sb.AppendLine(" private System.Collections.Generic.List? _subscriptions;"); + sb.AppendLine(" private int _nextId;"); break; case 2: // Concurrent if (config.Order == 0) // RegistrationOrder { - // Use ImmutableList for order preservation with concurrent access - code.AppendLine("private System.Collections.Immutable.ImmutableList _subscriptions = System.Collections.Immutable.ImmutableList.Empty;"); - code.AppendLine("private int _nextId;"); + sb.AppendLine(" private System.Collections.Immutable.ImmutableList? _subscriptions;"); + sb.AppendLine(" private int _nextId;"); } - else + else // Undefined { - // Can use ConcurrentBag for better performance - code.AppendLine("private System.Collections.Concurrent.ConcurrentBag _subscriptions = new();"); - code.AppendLine("private int _nextId;"); + sb.AppendLine(" private System.Collections.Concurrent.ConcurrentBag? _subscriptions;"); + sb.AppendLine(" private int _nextId;"); } break; } - - code.Unindent(); + sb.AppendLine(); } - private static void GenerateSubscribeMethods(CodeBuilder code, ObserverConfig config) + private static void GenerateSubscribeMethods(StringBuilder sb, string payloadType, ObserverConfig config) { - code.Indent(); - - // Determine generic parameter (we'll use object for now, but ideally would infer from context) - var eventType = "TEvent"; - if (!config.ForceAsync) { - // Generate sync Subscribe method - code.AppendLine($"public System.IDisposable Subscribe<{eventType}>(System.Action<{eventType}> handler)"); - code.AppendLine("{"); - code.Indent(); - GenerateSubscribeBody(code, config, false); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine(); + sb.AppendLine($" public System.IDisposable Subscribe(System.Action<{payloadType}> handler)"); + sb.AppendLine(" {"); + sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _nextId);"); + sb.AppendLine(" var sub = new Subscription(this, id, handler, false);"); + GenerateAddSubscription(sb, config, " "); + sb.AppendLine(" return sub;"); + sb.AppendLine(" }"); + sb.AppendLine(); } if (config.GenerateAsync) { - // Generate async Subscribe method - code.AppendLine($"public System.IDisposable Subscribe<{eventType}>(System.Func<{eventType}, System.Threading.Tasks.ValueTask> handler)"); - code.AppendLine("{"); - code.Indent(); - GenerateSubscribeBody(code, config, true); - code.Unindent(); - code.AppendLine("}"); + sb.AppendLine($" public System.IDisposable Subscribe(System.Func<{payloadType}, System.Threading.Tasks.ValueTask> handler)"); + sb.AppendLine(" {"); + sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _nextId);"); + sb.AppendLine(" var sub = new Subscription(this, id, handler, true);"); + GenerateAddSubscription(sb, config, " "); + sb.AppendLine(" return sub;"); + sb.AppendLine(" }"); + sb.AppendLine(); } - - code.Unindent(); } - private static void GenerateSubscribeBody(CodeBuilder code, ObserverConfig config, bool isAsync) + private static void GenerateAddSubscription(StringBuilder sb, ObserverConfig config, string indent) { - code.AppendLine("var id = System.Threading.Interlocked.Increment(ref _nextId);"); - code.AppendLine($"var sub = new Subscription(this, id, handler, {(isAsync ? "true" : "false")});"); - code.AppendLine(); - switch (config.Threading) { case 0: // SingleThreadedFast - code.AppendLine("_subscriptions.Add(sub);"); + sb.AppendLine($"{indent}(_subscriptions ??= new()).Add(sub);"); break; case 1: // Locking - code.AppendLine("lock (_lock)"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("_subscriptions.Add(sub);"); - code.Unindent(); - code.AppendLine("}"); + sb.AppendLine($"{indent}lock (_lock ??= new())"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} (_subscriptions ??= new()).Add(sub);"); + sb.AppendLine($"{indent}}}"); break; case 2: // Concurrent if (config.Order == 0) // RegistrationOrder { - code.AppendLine("System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, s) => list.Add(s), sub);"); + sb.AppendLine($"{indent}System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, s) => (list ?? System.Collections.Immutable.ImmutableList.Empty).Add(s), sub);"); } - else + else // Undefined { - code.AppendLine("_subscriptions.Add(sub);"); + sb.AppendLine($"{indent}(_subscriptions ??= new()).Add(sub);"); } break; } - - code.AppendLine(); - code.AppendLine("return sub;"); } - private static void GeneratePublishMethods(CodeBuilder code, ObserverConfig config) + private static void GeneratePublishMethods(StringBuilder sb, string payloadType, ObserverConfig config) { - code.Indent(); - - var eventType = "TEvent"; - if (!config.ForceAsync) { - // Generate sync Publish method - code.AppendLine($"public void Publish<{eventType}>({eventType} value)"); - code.AppendLine("{"); - code.Indent(); - GenerateSyncPublishBody(code, config); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine(); - } - - if (config.GenerateAsync) - { - // Generate async Publish method - code.AppendLine($"public async System.Threading.Tasks.ValueTask PublishAsync<{eventType}>({eventType} value, System.Threading.CancellationToken cancellationToken = default)"); - code.AppendLine("{"); - code.Indent(); - GenerateAsyncPublishBody(code, config); - code.Unindent(); - code.AppendLine("}"); - } - - code.Unindent(); - } + sb.AppendLine($" public void Publish({payloadType} payload)"); + sb.AppendLine(" {"); + GenerateSnapshot(sb, config, " "); + sb.AppendLine(); - private static void GenerateSyncPublishBody(CodeBuilder code, ObserverConfig config) - { - // Take snapshot based on threading policy - GenerateSnapshot(code, config); - code.AppendLine(); - - // Generate exception handling setup - if (config.Exceptions == 2) // Aggregate - { - code.AppendLine("System.Collections.Generic.List? errors = null;"); - code.AppendLine(); - } - - // Iterate through snapshot - code.AppendLine("foreach (var sub in snapshot)"); - code.AppendLine("{"); - code.Indent(); + if (config.Exceptions == 2) // Aggregate + { + sb.AppendLine(" System.Collections.Generic.List? errors = null;"); + sb.AppendLine(); + } - code.AppendLine("if (sub.IsAsync) continue; // Skip async handlers in sync publish"); - code.AppendLine(); + sb.AppendLine(" foreach (var sub in snapshot)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (sub.IsAsync) continue;"); - // Try-catch based on exception policy - if (config.Exceptions == 1) // Stop - { - code.AppendLine("sub.InvokeSync(value);"); - } - else - { - code.AppendLine("try"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("sub.InvokeSync(value);"); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine("catch (System.Exception ex)"); - code.AppendLine("{"); - code.Indent(); - - if (config.Exceptions == 0) // Continue + if (config.Exceptions == 1) // Stop { - code.AppendLine("// Optionally call OnSubscriberError if defined"); - code.AppendLine("OnSubscriberError(ex);"); + sb.AppendLine(" sub.InvokeSync(payload);"); } - else if (config.Exceptions == 2) // Aggregate + else { - code.AppendLine("(errors ??= new()).Add(ex);"); + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" sub.InvokeSync(payload);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + if (config.Exceptions == 0) // Continue + { + sb.AppendLine(" OnSubscriberError(ex);"); + } + else // Aggregate + { + sb.AppendLine(" (errors ??= new()).Add(ex);"); + } + sb.AppendLine(" }"); } - code.Unindent(); - code.AppendLine("}"); - } + sb.AppendLine(" }"); - code.Unindent(); - code.AppendLine("}"); + if (config.Exceptions == 2) + { + sb.AppendLine(); + sb.AppendLine(" if (errors is { Count: > 0 })"); + sb.AppendLine(" throw new System.AggregateException(errors);"); + } - // Throw aggregate if needed - if (config.Exceptions == 2) - { - code.AppendLine(); - code.AppendLine("if (errors is { Count: > 0 })"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("throw new System.AggregateException(errors);"); - code.Unindent(); - code.AppendLine("}"); + sb.AppendLine(" }"); + sb.AppendLine(); } - } - - private static void GenerateAsyncPublishBody(CodeBuilder code, ObserverConfig config) - { - // Take snapshot - GenerateSnapshot(code, config); - code.AppendLine(); - // Generate exception handling setup - if (config.Exceptions == 2) // Aggregate + if (config.GenerateAsync) { - code.AppendLine("System.Collections.Generic.List? errors = null;"); - code.AppendLine(); - } + sb.AppendLine($" public async System.Threading.Tasks.ValueTask PublishAsync({payloadType} payload, System.Threading.CancellationToken cancellationToken = default)"); + sb.AppendLine(" {"); + GenerateSnapshot(sb, config, " "); + sb.AppendLine(); - // Iterate through snapshot - code.AppendLine("foreach (var sub in snapshot)"); - code.AppendLine("{"); - code.Indent(); + if (config.Exceptions == 2) + { + sb.AppendLine(" System.Collections.Generic.List? errors = null;"); + sb.AppendLine(); + } - // Check cancellation - code.AppendLine("if (cancellationToken.IsCancellationRequested) break;"); - code.AppendLine(); + sb.AppendLine(" foreach (var sub in snapshot)"); + sb.AppendLine(" {"); + sb.AppendLine(" cancellationToken.ThrowIfCancellationRequested();"); - // Try-catch based on exception policy - if (config.Exceptions == 1) // Stop - { - code.AppendLine("await sub.InvokeAsync(value, cancellationToken).ConfigureAwait(false);"); - } - else - { - code.AppendLine("try"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("await sub.InvokeAsync(value, cancellationToken).ConfigureAwait(false);"); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine("catch (System.Exception ex)"); - code.AppendLine("{"); - code.Indent(); - - if (config.Exceptions == 0) // Continue + if (config.Exceptions == 1) // Stop { - code.AppendLine("OnSubscriberError(ex);"); + sb.AppendLine(" await sub.InvokeAsync(payload, cancellationToken).ConfigureAwait(false);"); } - else if (config.Exceptions == 2) // Aggregate + else { - code.AppendLine("(errors ??= new()).Add(ex);"); + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, cancellationToken).ConfigureAwait(false);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + if (config.Exceptions == 0) // Continue + { + sb.AppendLine(" OnSubscriberError(ex);"); + } + else // Aggregate + { + sb.AppendLine(" (errors ??= new()).Add(ex);"); + } + sb.AppendLine(" }"); } - code.Unindent(); - code.AppendLine("}"); - } + sb.AppendLine(" }"); - code.Unindent(); - code.AppendLine("}"); + if (config.Exceptions == 2) + { + sb.AppendLine(); + sb.AppendLine(" if (errors is { Count: > 0 })"); + sb.AppendLine(" throw new System.AggregateException(errors);"); + } - // Throw aggregate if needed - if (config.Exceptions == 2) - { - code.AppendLine(); - code.AppendLine("if (errors is { Count: > 0 })"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("throw new System.AggregateException(errors);"); - code.Unindent(); - code.AppendLine("}"); + sb.AppendLine(" }"); + sb.AppendLine(); } } - private static void GenerateSnapshot(CodeBuilder code, ObserverConfig config) + private static void GenerateSnapshot(StringBuilder sb, ObserverConfig config, string indent) { switch (config.Threading) { case 0: // SingleThreadedFast - code.AppendLine("var snapshot = _subscriptions.ToArray();"); + sb.AppendLine($"{indent}var snapshot = _subscriptions?.ToArray() ?? System.Array.Empty();"); break; case 1: // Locking - code.AppendLine("Subscription[] snapshot;"); - code.AppendLine("lock (_lock)"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("snapshot = _subscriptions.ToArray();"); - code.Unindent(); - code.AppendLine("}"); + sb.AppendLine($"{indent}Subscription[] snapshot;"); + sb.AppendLine($"{indent}lock (_lock ?? new object())"); + sb.AppendLine($"{indent}{{"); + sb.AppendLine($"{indent} snapshot = _subscriptions?.ToArray() ?? System.Array.Empty();"); + sb.AppendLine($"{indent}}}"); break; case 2: // Concurrent if (config.Order == 0) // RegistrationOrder { - code.AppendLine("var snapshot = System.Threading.Volatile.Read(ref _subscriptions).ToArray();"); + sb.AppendLine($"{indent}var snapshot = System.Threading.Volatile.Read(ref _subscriptions)?.ToArray() ?? System.Array.Empty();"); } - else + else // Undefined { - code.AppendLine("var snapshot = _subscriptions.ToArray();"); + sb.AppendLine($"{indent}var snapshot = _subscriptions?.ToArray() ?? System.Array.Empty();"); } break; } } - private static void GenerateSubscriptionClass(CodeBuilder code, ObserverConfig config) + private static void GenerateUnsubscribeMethod(StringBuilder sb, ObserverConfig config) { - code.Indent(); - - code.AppendLine("partial void OnSubscriberError(System.Exception ex) { }"); - code.AppendLine(); - - code.AppendLine("private void Unsubscribe(int id)"); - code.AppendLine("{"); - code.Indent(); + sb.AppendLine(" private void Unsubscribe(int id)"); + sb.AppendLine(" {"); switch (config.Threading) { case 0: // SingleThreadedFast - code.AppendLine("_subscriptions.RemoveAll(s => s.Id == id);"); + sb.AppendLine(" _subscriptions?.RemoveAll(s => s.Id == id);"); break; case 1: // Locking - code.AppendLine("lock (_lock)"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("_subscriptions.RemoveAll(s => s.Id == id);"); - code.Unindent(); - code.AppendLine("}"); + sb.AppendLine(" lock (_lock ?? new object())"); + sb.AppendLine(" {"); + sb.AppendLine(" _subscriptions?.RemoveAll(s => s.Id == id);"); + sb.AppendLine(" }"); break; case 2: // Concurrent if (config.Order == 0) // RegistrationOrder { - code.AppendLine("System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, id) => list.RemoveAll(s => s.Id == id), id);"); + sb.AppendLine(" System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, id) => list?.RemoveAll(s => s.Id == id) ?? list, id);"); } - else + else // Undefined - ConcurrentBag doesn't support efficient removal { - code.AppendLine("// Note: ConcurrentBag doesn't support removal efficiently"); - code.AppendLine("// Mark as removed instead"); + sb.AppendLine(" // ConcurrentBag doesn't support removal; subscription marks itself disposed"); } break; } - code.Unindent(); - code.AppendLine("}"); - code.AppendLine(); - - // Generate Subscription nested class - code.AppendLine("private sealed class Subscription : System.IDisposable"); - code.AppendLine("{"); - code.Indent(); - - code.AppendLine("private readonly object _parent;"); - code.AppendLine("private readonly int _id;"); - code.AppendLine("private readonly object _handler;"); - code.AppendLine("private readonly bool _isAsync;"); - code.AppendLine("private int _disposed;"); - code.AppendLine(); - - code.AppendLine("public int Id => _id;"); - code.AppendLine("public bool IsAsync => _isAsync;"); - code.AppendLine(); - - code.AppendLine("public Subscription(object parent, int id, object handler, bool isAsync)"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("_parent = parent;"); - code.AppendLine("_id = id;"); - code.AppendLine("_handler = handler;"); - code.AppendLine("_isAsync = isAsync;"); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine(); - - code.AppendLine("public void InvokeSync(TEvent value)"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("if (System.Threading.Volatile.Read(ref _disposed) != 0) return;"); - code.AppendLine("((System.Action)_handler)(value);"); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine(); - - code.AppendLine("public System.Threading.Tasks.ValueTask InvokeAsync(TEvent value, System.Threading.CancellationToken ct)"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("if (System.Threading.Volatile.Read(ref _disposed) != 0) return default;"); - code.AppendLine("if (_isAsync)"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("return ((System.Func)_handler)(value);"); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine("else"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("((System.Action)_handler)(value);"); - code.AppendLine("return default;"); - code.Unindent(); - code.AppendLine("}"); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine(); - - code.AppendLine("public void Dispose()"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine("if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;"); - code.AppendLine("((dynamic)_parent).Unsubscribe(_id);"); - code.Unindent(); - code.AppendLine("}"); - - code.Unindent(); - code.AppendLine("}"); - - code.Unindent(); - } - - private static string GenerateHubSource(INamedTypeSymbol typeSymbol, List eventProperties) - { - var ns = typeSymbol.ContainingNamespace.IsGlobalNamespace - ? null - : typeSymbol.ContainingNamespace.ToDisplayString(); - - var typeName = typeSymbol.Name; - - var code = new CodeBuilder(); - - code.AppendLine("#nullable enable"); - code.AppendLine("// "); - code.AppendLine(); - - if (ns != null) - { - code.AppendLine($"namespace {ns};"); - code.AppendLine(); - } - - code.AppendLine($"{GetAccessibility(typeSymbol)} static partial class {typeName}"); - code.AppendLine("{"); - code.Indent(); - - // Generate each event property - foreach (var prop in eventProperties) - { - var propType = prop.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); - var propName = prop.Name; - - code.AppendLine($"private static readonly {propType} _{char.ToLower(propName[0])}{propName.Substring(1)} = new();"); - code.AppendLine(); - code.AppendLine($"public static partial {propType} {propName}"); - code.AppendLine("{"); - code.Indent(); - code.AppendLine($"get => _{char.ToLower(propName[0])}{propName.Substring(1)};"); - code.Unindent(); - code.AppendLine("}"); - code.AppendLine(); - } - - code.Unindent(); - code.AppendLine("}"); - - return code.ToString(); + sb.AppendLine(" }"); + sb.AppendLine(); } - private static string GetTypeKind(INamedTypeSymbol symbol) + private static void GenerateOnErrorHook(StringBuilder sb) { - return symbol.TypeKind switch - { - TypeKind.Class => symbol.IsRecord ? "record class" : "class", - TypeKind.Struct => symbol.IsRecord ? "record struct" : "struct", - _ => "class" - }; + sb.AppendLine(" partial void OnSubscriberError(System.Exception ex);"); + sb.AppendLine(); } - private static string GetAccessibility(ISymbol symbol) + private static void GenerateSubscriptionClass(StringBuilder sb, string payloadType, ObserverConfig config) { - return symbol.DeclaredAccessibility switch - { - Accessibility.Public => "public", - Accessibility.Internal => "internal", - Accessibility.Private => "private", - Accessibility.Protected => "protected", - Accessibility.ProtectedOrInternal => "protected internal", - _ => "internal" - }; + sb.AppendLine(" private sealed class Subscription : System.IDisposable"); + sb.AppendLine(" {"); + sb.AppendLine(" private readonly object _parent;"); + sb.AppendLine(" private readonly int _id;"); + sb.AppendLine(" private readonly object _handler;"); + sb.AppendLine(" private readonly bool _isAsync;"); + sb.AppendLine(" private int _disposed;"); + sb.AppendLine(); + sb.AppendLine(" public int Id => _id;"); + sb.AppendLine(" public bool IsAsync => _isAsync;"); + sb.AppendLine(); + sb.AppendLine(" public Subscription(object parent, int id, object handler, bool isAsync)"); + sb.AppendLine(" {"); + sb.AppendLine(" _parent = parent;"); + sb.AppendLine(" _id = id;"); + sb.AppendLine(" _handler = handler;"); + sb.AppendLine(" _isAsync = isAsync;"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine($" public void InvokeSync({payloadType} payload)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (System.Threading.Volatile.Read(ref _disposed) != 0) return;"); + sb.AppendLine($" ((System.Action<{payloadType}>)_handler)(payload);"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine($" public System.Threading.Tasks.ValueTask InvokeAsync({payloadType} payload, System.Threading.CancellationToken ct)"); + sb.AppendLine(" {"); + sb.AppendLine(" if (System.Threading.Volatile.Read(ref _disposed) != 0) return default;"); + sb.AppendLine(" if (_isAsync)"); + sb.AppendLine($" return ((System.Func<{payloadType}, System.Threading.Tasks.ValueTask>)_handler)(payload);"); + sb.AppendLine(" else"); + sb.AppendLine(" {"); + sb.AppendLine($" ((System.Action<{payloadType}>)_handler)(payload);"); + sb.AppendLine(" return default;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" public void Dispose()"); + sb.AppendLine(" {"); + sb.AppendLine(" if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;"); + sb.AppendLine(" ((dynamic)_parent).Unsubscribe(_id);"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); } - private class ObserverConfig + private sealed class ObserverConfig { public int Threading { get; set; } = 1; // Locking public int Exceptions { get; set; } = 0; // Continue @@ -761,29 +494,4 @@ private class ObserverConfig public bool GenerateAsync { get; set; } = true; public bool ForceAsync { get; set; } = false; } - - private class CodeBuilder - { - private readonly System.Text.StringBuilder _sb = new(); - private int _indentLevel = 0; - private const string IndentString = " "; - - public void Indent() => _indentLevel++; - public void Unindent() => _indentLevel = Math.Max(0, _indentLevel - 1); - - public void Append(string text) => _sb.Append(text); - - public void AppendLine(string text = "") - { - if (!string.IsNullOrEmpty(text)) - { - for (int i = 0; i < _indentLevel; i++) - _sb.Append(IndentString); - _sb.Append(text); - } - _sb.AppendLine(); - } - - public override string ToString() => _sb.ToString(); - } } From 7d51442761f78923724288530e55ebd2a185ad54 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:38:58 +0000 Subject: [PATCH 05/13] Add payload type constructor to ObserverAttribute and fix thread safety issues - Add ObserverAttribute(Type payloadType) constructor to accept payload type - Add PayloadType property to ObserverAttribute - Update documentation example to show [Observer(typeof(Temperature))] - Fix critical thread safety bugs in lock usage: * Use lock field assignment to ensure same lock object is used * Change 'lock (_lock ?? new object())' to proper initialization - Add defensive null check for Attributes array access - Ensure lock object is initialized before use in Publish and Unsubscribe These fixes address code review feedback and ensure proper thread-safe operation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Observer/ObserverAttribute.cs | 16 +++++++++++++++- .../Observer/ObserverGenerator.cs | 10 ++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs index 8057e11..7d40bdc 100644 --- a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs @@ -12,7 +12,7 @@ namespace PatternKit.Generators.Observer; /// /// Example: /// -/// [Observer] +/// [Observer(typeof(Temperature))] /// public partial class TemperatureChanged { } /// /// @@ -20,6 +20,20 @@ namespace PatternKit.Generators.Observer; [AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] public sealed class ObserverAttribute : Attribute { + /// + /// Initializes a new instance of the class. + /// + /// The type of the event payload. + public ObserverAttribute(Type payloadType) + { + PayloadType = payloadType; + } + + /// + /// Gets the type of the event payload. + /// + public Type PayloadType { get; } + /// /// Gets or sets the threading policy for Subscribe/Unsubscribe/Publish operations. /// Default is . diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs index 8fc0de9..74280b7 100644 --- a/src/PatternKit.Generators/Observer/ObserverGenerator.cs +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -67,8 +67,8 @@ private static void GenerateObserver(SourceProductionContext context, GeneratorA return; } - var attr = occurrence.Attributes[0]; - if (attr.ConstructorArguments.Length == 0 || attr.ConstructorArguments[0].Value is not INamedTypeSymbol payloadType) + var attr = occurrence.Attributes.Length > 0 ? occurrence.Attributes[0] : null; + if (attr == null || attr.ConstructorArguments.Length == 0 || attr.ConstructorArguments[0].Value is not INamedTypeSymbol payloadType) { context.ReportDiagnostic(Diagnostic.Create( MissingPayloadRule, @@ -380,7 +380,8 @@ private static void GenerateSnapshot(StringBuilder sb, ObserverConfig config, st case 1: // Locking sb.AppendLine($"{indent}Subscription[] snapshot;"); - sb.AppendLine($"{indent}lock (_lock ?? new object())"); + sb.AppendLine($"{indent}var lockObj = _lock ??= new object();"); + sb.AppendLine($"{indent}lock (lockObj)"); sb.AppendLine($"{indent}{{"); sb.AppendLine($"{indent} snapshot = _subscriptions?.ToArray() ?? System.Array.Empty();"); sb.AppendLine($"{indent}}}"); @@ -411,7 +412,8 @@ private static void GenerateUnsubscribeMethod(StringBuilder sb, ObserverConfig c break; case 1: // Locking - sb.AppendLine(" lock (_lock ?? new object())"); + sb.AppendLine(" var lockObj = _lock ??= new object();"); + sb.AppendLine(" lock (lockObj)"); sb.AppendLine(" {"); sb.AppendLine(" _subscriptions?.RemoveAll(s => s.Id == id);"); sb.AppendLine(" }"); From ca8f6c8bdede00575bd75019719571ac78c52bf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:42:41 +0000 Subject: [PATCH 06/13] Complete rewrite of ObserverGenerator with payload type support Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../packages.lock.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/PatternKit.Generators.Tests/packages.lock.json b/test/PatternKit.Generators.Tests/packages.lock.json index 55fe82a..00b627e 100644 --- a/test/PatternKit.Generators.Tests/packages.lock.json +++ b/test/PatternKit.Generators.Tests/packages.lock.json @@ -386,7 +386,10 @@ "Microsoft.TestPlatform.ObjectModel": { "type": "Transitive", "resolved": "18.0.1", - "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==" + "contentHash": "qT/mwMcLF9BieRkzOBPL2qCopl8hQu6A1P7JWAoj/FMu5i9vds/7cjbJ/LLtaiwWevWLAeD5v5wjQJ/l6jvhWQ==", + "dependencies": { + "System.Reflection.Metadata": "8.0.0" + } }, "Microsoft.TestPlatform.TestHost": { "type": "Transitive", @@ -407,6 +410,14 @@ "resolved": "10.0.3", "contentHash": "+bZnyzt0/vt4g3QSllhsRNGTpa09p7Juy5K8spcK73cOTOefu4+HoY89hZOgIOmzB5A4hqPyEDKnzra7KKnhZw==" }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "ptvgrFh7PvWI8bcVqG5rsA/weWM09EnthFHR5SCnS6IN+P4mj6rE1lBDC4U8HL9/57htKAqy4KQ3bBj84cfYyQ==", + "dependencies": { + "System.Collections.Immutable": "8.0.0" + } + }, "xunit.abstractions": { "type": "Transitive", "resolved": "2.0.3", @@ -469,7 +480,8 @@ "patternkit.generators": { "type": "Project", "dependencies": { - "PatternKit.Generators.Abstractions": "[1.0.0, )" + "PatternKit.Generators.Abstractions": "[1.0.0, )", + "System.Collections.Immutable": "[10.0.3, )" } }, "patternkit.generators.abstractions": { From 1a04bcdf79ccf5563555a9e53753a78509c5f0f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:46:56 +0000 Subject: [PATCH 07/13] Fix dynamic cast issue in ObserverGenerator, 10/12 tests passing Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../Observer/ObserverGenerator.cs | 11 +- .../ObserverGeneratorTests.cs | 613 ++++++++++++++++++ 2 files changed, 621 insertions(+), 3 deletions(-) create mode 100644 test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs index 74280b7..b8c5c4c 100644 --- a/src/PatternKit.Generators/Observer/ObserverGenerator.cs +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -443,9 +443,10 @@ private static void GenerateOnErrorHook(StringBuilder sb) private static void GenerateSubscriptionClass(StringBuilder sb, string payloadType, ObserverConfig config) { - sb.AppendLine(" private sealed class Subscription : System.IDisposable"); + // Use generic parent reference to avoid dynamic + sb.AppendLine($" private sealed class Subscription : System.IDisposable"); sb.AppendLine(" {"); - sb.AppendLine(" private readonly object _parent;"); + sb.AppendLine(" private object? _parent;"); sb.AppendLine(" private readonly int _id;"); sb.AppendLine(" private readonly object _handler;"); sb.AppendLine(" private readonly bool _isAsync;"); @@ -483,7 +484,11 @@ private static void GenerateSubscriptionClass(StringBuilder sb, string payloadTy sb.AppendLine(" public void Dispose()"); sb.AppendLine(" {"); sb.AppendLine(" if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;"); - sb.AppendLine(" ((dynamic)_parent).Unsubscribe(_id);"); + sb.AppendLine(" // Use reflection to call Unsubscribe since parent type is generic"); + sb.AppendLine(" var parent = System.Threading.Interlocked.Exchange(ref _parent, null);"); + sb.AppendLine(" if (parent == null) return;"); + sb.AppendLine(" var method = parent.GetType().GetMethod(\"Unsubscribe\", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);"); + sb.AppendLine(" method?.Invoke(parent, new object[] { _id });"); sb.AppendLine(" }"); sb.AppendLine(" }"); } diff --git a/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs b/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs new file mode 100644 index 0000000..e2fc4b8 --- /dev/null +++ b/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs @@ -0,0 +1,613 @@ +using Microsoft.CodeAnalysis; +using System.Runtime.Loader; + +namespace PatternKit.Generators.Tests; + +public class ObserverGeneratorTests +{ + private const string SimpleObserver = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial class TemperatureChanged + { + } + """; + + [Fact] + public void Generates_Observer_Without_Diagnostics() + { + var comp = RoslynTestHelpers.CreateCompilation( + SimpleObserver, + assemblyName: nameof(Generates_Observer_Without_Diagnostics)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out var updated); + + // No generator diagnostics + Assert.All(run.Results, r => Assert.Empty(r.Diagnostics)); + + // Confirm we generated expected file + var sources = run.Results.SelectMany(r => r.GeneratedSources).ToArray(); + Assert.Single(sources); + Assert.Contains("Observer.g.cs", sources[0].HintName); + + // The updated compilation should compile + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Reports_Error_When_Type_Not_Partial() + { + var code = """ + using PatternKit.Generators.Observer; + namespace Test; + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public class TemperatureChanged { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + code, + assemblyName: nameof(Reports_Error_When_Type_Not_Partial)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKOBS001"); + } + + [Fact] + public void Subscribe_And_Publish_Works() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add($"Handler1:{t.Celsius}")); + evt.Subscribe((Temperature t) => log.Add($"Handler2:{t.Celsius}")); + + evt.Publish(new Temperature(23.5)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Subscribe_And_Publish_Works)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + // Load and invoke Demo.Run + using var pe = new MemoryStream(); + using var pdb = new MemoryStream(); + var res = updated.Emit(pe, pdb); + Assert.True(res.Success); + + pe.Position = 0; + pdb.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe, pdb); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + Assert.NotNull(demoType); + + var runMethod = demoType.GetMethod("Run"); + Assert.NotNull(runMethod); + + var result = (string)runMethod.Invoke(null, null)!; + Assert.Equal("Handler1:23.5|Handler2:23.5", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Dispose_Removes_Subscription() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + var sub1 = evt.Subscribe((Temperature t) => log.Add($"H1:{t.Celsius}")); + var sub2 = evt.Subscribe((Temperature t) => log.Add($"H2:{t.Celsius}")); + + evt.Publish(new Temperature(10)); + sub1.Dispose(); + evt.Publish(new Temperature(20)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Dispose_Removes_Subscription)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + + // After first publish: both handlers; after second: only H2 + Assert.Equal("H1:10|H2:10|H2:20", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Registration_Order_Preserved() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add("A")); + evt.Subscribe((Temperature t) => log.Add("B")); + evt.Subscribe((Temperature t) => log.Add("C")); + + evt.Publish(new Temperature(0)); + + return string.Join("", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Registration_Order_Preserved)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + Assert.Equal("ABC", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Async_Subscribe_And_PublishAsync_Works() + { + var user = SimpleObserver + """ + + public static class Demo + { + public static async System.Threading.Tasks.Task Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe(async (Temperature t) => + { + await System.Threading.Tasks.Task.Delay(1); + log.Add($"AsyncHandler:{t.Celsius}"); + }); + + await evt.PublishAsync(new Temperature(42)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Async_Subscribe_And_PublishAsync_Works)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var task = (System.Threading.Tasks.Task)runMethod!.Invoke(null, null)!; + task.Wait(); + var result = task.Result; + Assert.Equal("AsyncHandler:42", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Exception_Policy_Continue_Does_Not_Stop_Execution() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature), Exceptions = ObserverExceptionPolicy.Continue)] + public partial class TemperatureChanged + { + partial void OnSubscriberError(System.Exception ex) + { + // Swallow the error + } + } + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add("H1")); + evt.Subscribe((Temperature t) => throw new System.Exception("Oops")); + evt.Subscribe((Temperature t) => log.Add("H3")); + + evt.Publish(new Temperature(0)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Exception_Policy_Continue_Does_Not_Stop_Execution)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + + // All three handlers should execute (H1, exception, H3) + Assert.Equal("H1|H3", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Exception_Policy_Stop_Throws_First_Exception() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature), Exceptions = ObserverExceptionPolicy.Stop)] + public partial class TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => { }); + evt.Subscribe((Temperature t) => throw new System.Exception("Oops")); + evt.Subscribe((Temperature t) => { }); + + try + { + evt.Publish(new Temperature(0)); + return "No exception"; + } + catch (System.Exception ex) + { + return ex.Message; + } + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Exception_Policy_Stop_Throws_First_Exception)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + Assert.Equal("Oops", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Exception_Policy_Aggregate_Throws_AggregateException() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature), Exceptions = ObserverExceptionPolicy.Aggregate)] + public partial class TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => throw new System.Exception("Error1")); + evt.Subscribe((Temperature t) => throw new System.Exception("Error2")); + + try + { + evt.Publish(new Temperature(0)); + return "No exception"; + } + catch (System.AggregateException ex) + { + return $"{ex.InnerExceptions.Count}:{ex.InnerExceptions[0].Message}:{ex.InnerExceptions[1].Message}"; + } + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Exception_Policy_Aggregate_Throws_AggregateException)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + Assert.Equal("2:Error1:Error2", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Supports_Struct_Type() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial struct TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add($"T:{t.Celsius}")); + evt.Publish(new Temperature(99)); + + return string.Join("|", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Supports_Struct_Type)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var result = (string)runMethod!.Invoke(null, null)!; + Assert.Equal("T:99", result); + } + finally + { + alc.Unload(); + } + } + + [Fact] + public void Supports_Record_Class() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial record class TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add("OK")); + evt.Publish(new Temperature(0)); + + return string.Join("", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Supports_Record_Class)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } + + [Fact] + public void Supports_Record_Struct() + { + var user = """ + using PatternKit.Generators.Observer; + + namespace PatternKit.Examples.Generators; + + public record Temperature(double Celsius); + + [Observer(typeof(Temperature))] + public partial record struct TemperatureChanged + { + } + + public static class Demo + { + public static string Run() + { + var log = new System.Collections.Generic.List(); + var evt = new TemperatureChanged(); + + evt.Subscribe((Temperature t) => log.Add("OK")); + evt.Publish(new Temperature(0)); + + return string.Join("", log); + } + } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Supports_Record_Struct)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); + + var emit = updated.Emit(Stream.Null); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + } +} From 9102caa60077b69460c7fb53a8a45011aeb4d85b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:53:50 +0000 Subject: [PATCH 08/13] Add comprehensive Observer pattern documentation and examples - Created comprehensive documentation at docs/generators/observer.md - Overview and motivation - Basic usage examples with sync and async handlers - Configuration options (threading, exceptions, ordering) - Complete API reference (Subscribe, Publish, PublishAsync) - Best practices and performance considerations - Common patterns (observable properties, event aggregator) - Diagnostics reference (PKOBS001-003) - Troubleshooting guide - Added real-world examples in src/PatternKit.Examples/ObserverGeneratorDemo/ - TemperatureMonitor.cs: Basic Observer usage with temperature sensors - Demonstrates sync handlers, multiple subscribers - Exception handling with OnSubscriberError - Subscription lifecycle management - NotificationSystem.cs: Advanced async patterns - Multi-channel notifications (Email, SMS, Push) - Async handlers with PublishAsync - Exception policies (Continue vs Aggregate) - Cancellation token support - README.md: Comprehensive example documentation - Quick start guide - Configuration examples - Common patterns - Running instructions - Updated docs/generators/toc.yml to include observer.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/generators/observer.md | 818 ++++++++++++++++++ docs/generators/toc.yml | 3 + .../NotificationSystem.cs | 375 ++++++++ .../ObserverGeneratorDemo/README.md | 288 ++++++ .../TemperatureMonitor.cs | 244 ++++++ 5 files changed, 1728 insertions(+) create mode 100644 docs/generators/observer.md create mode 100644 src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs create mode 100644 src/PatternKit.Examples/ObserverGeneratorDemo/README.md create mode 100644 src/PatternKit.Examples/ObserverGeneratorDemo/TemperatureMonitor.cs diff --git a/docs/generators/observer.md b/docs/generators/observer.md new file mode 100644 index 0000000..f9d702e --- /dev/null +++ b/docs/generators/observer.md @@ -0,0 +1,818 @@ +# Observer Pattern Generator + +## Overview + +The **Observer Generator** creates type-safe, high-performance Observer pattern implementations with configurable threading, exception handling, and ordering semantics. It eliminates the need to manually write subscription management code, providing compile-time safety and optimal runtime performance. + +## When to Use + +Use the Observer generator when you need: + +- **Event notification systems**: Publish events to multiple subscribers +- **Reactive programming**: Build observable data streams and change notifications +- **Decoupled communication**: Publishers don't need to know about subscribers +- **Type-safe event handling**: Compile-time verification of handler signatures +- **Configurable behavior**: Control threading, exceptions, and ordering + +## Installation + +The generator is included in the `PatternKit.Generators` package: + +```bash +dotnet add package PatternKit.Generators +``` + +## Quick Start + +```csharp +using PatternKit.Generators.Observer; + +public record Temperature(double Celsius); + +[Observer(typeof(Temperature))] +public partial class TemperatureChanged +{ +} +``` + +Generated methods: + +```csharp +public partial class TemperatureChanged +{ + // Subscribe with sync handler + public IDisposable Subscribe(Action handler) { ... } + + // Subscribe with async handler + public IDisposable Subscribe(Func handler) { ... } + + // Publish to all subscribers + public void Publish(Temperature payload) { ... } + + // Publish asynchronously + public ValueTask PublishAsync(Temperature payload, CancellationToken cancellationToken = default) { ... } +} +``` + +Usage: + +```csharp +var tempEvent = new TemperatureChanged(); + +// Subscribe to events +var subscription = tempEvent.Subscribe(temp => + Console.WriteLine($"Temperature: {temp.Celsius}°C")); + +// Publish events +tempEvent.Publish(new Temperature(23.5)); +tempEvent.Publish(new Temperature(24.0)); + +// Unsubscribe +subscription.Dispose(); +``` + +## Basic Usage + +### Synchronous Handlers + +```csharp +public record StockPrice(string Symbol, decimal Price); + +[Observer(typeof(StockPrice))] +public partial class StockPriceChanged +{ +} + +// Usage +var priceEvent = new StockPriceChanged(); + +priceEvent.Subscribe(price => + Console.WriteLine($"{price.Symbol}: ${price.Price}")); + +priceEvent.Subscribe(price => + LogToDatabase(price)); + +priceEvent.Publish(new StockPrice("MSFT", 420.50m)); +``` + +### Asynchronous Handlers + +```csharp +public record UserRegistration(string Email, DateTime Timestamp); + +[Observer(typeof(UserRegistration))] +public partial class UserRegistered +{ +} + +// Usage +var userEvent = new UserRegistered(); + +userEvent.Subscribe(async user => +{ + await SendWelcomeEmailAsync(user.Email); + await CreateUserProfileAsync(user); +}); + +await userEvent.PublishAsync( + new UserRegistration("user@example.com", DateTime.UtcNow)); +``` + +### Managing Subscriptions + +Subscriptions return `IDisposable` for cleanup: + +```csharp +var subscription1 = tempEvent.Subscribe(t => Console.WriteLine(t.Celsius)); +var subscription2 = tempEvent.Subscribe(t => LogTemperature(t)); + +// Unsubscribe individual handlers +subscription1.Dispose(); + +// Using 'using' for automatic cleanup +using (var sub = tempEvent.Subscribe(t => ProcessTemperature(t))) +{ + tempEvent.Publish(new Temperature(25.0)); +} // Automatically unsubscribed +``` + +## Configuration Options + +### Threading Policies + +Control how Subscribe/Publish operations handle concurrency: + +#### Locking (Default) + +Uses locking for thread safety. Recommended for most scenarios: + +```csharp +[Observer(typeof(Temperature), Threading = ObserverThreadingPolicy.Locking)] +public partial class TemperatureChanged { } +``` + +**Characteristics:** +- Thread-safe Subscribe/Unsubscribe/Publish +- Snapshots subscriber list under lock for predictable iteration +- Moderate overhead for lock acquisition + +**Use when:** +- Multiple threads may publish or subscribe concurrently +- You need deterministic ordering +- Default choice for most applications + +#### SingleThreadedFast + +No thread safety, maximum performance: + +```csharp +[Observer(typeof(UiEvent), Threading = ObserverThreadingPolicy.SingleThreadedFast)] +public partial class UiEventOccurred { } +``` + +**Characteristics:** +- No synchronization overhead +- Not thread-safe +- Lowest memory footprint + +**Use when:** +- All operations occur on a single thread (e.g., UI thread) +- Performance is critical +- You can guarantee no concurrent access + +⚠️ **Warning:** Using this policy with concurrent access will cause data corruption and race conditions. + +#### Concurrent + +Lock-free atomic operations for high concurrency: + +```csharp +[Observer(typeof(MetricUpdate), Threading = ObserverThreadingPolicy.Concurrent)] +public partial class MetricUpdated { } +``` + +**Characteristics:** +- Lock-free concurrent operations +- Thread-safe with better performance under high concurrency +- May have undefined ordering unless RegistrationOrder is used + +**Use when:** +- High-throughput scenarios with many concurrent publishers +- Minimizing lock contention is important +- Can tolerate potential ordering variations + +### Exception Policies + +Control how exceptions from handlers are managed: + +#### Continue (Default) + +Continue invoking all handlers even if some throw: + +```csharp +[Observer(typeof(Message), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class MessageReceived +{ + // Optional: handle errors from subscribers + partial void OnSubscriberError(Exception ex) + { + Logger.LogError(ex, "Subscriber failed"); + } +} +``` + +**Characteristics:** +- All handlers get invoked +- Exceptions are caught and optionally logged +- Publishing never throws + +**Use when:** +- Subscriber failures shouldn't affect other subscribers +- You want best-effort delivery +- Fault tolerance is important + +**Optional Hook:** Implement `partial void OnSubscriberError(Exception ex)` to log or handle errors. + +#### Stop + +Stop at first exception and rethrow: + +```csharp +[Observer(typeof(CriticalCommand), Exceptions = ObserverExceptionPolicy.Stop)] +public partial class CommandExecuted { } +``` + +**Characteristics:** +- First exception stops publishing +- Exception is rethrown to caller +- Remaining handlers are not invoked + +**Use when:** +- Any handler failure should abort the operation +- You need to handle errors at the call site +- Order matters and failures are critical + +#### Aggregate + +Collect all exceptions and throw AggregateException: + +```csharp +[Observer(typeof(ValidationRequest), Exceptions = ObserverExceptionPolicy.Aggregate)] +public partial class ValidationRequested { } +``` + +**Characteristics:** +- All handlers are invoked +- Exceptions are collected +- AggregateException thrown if any failed + +**Use when:** +- You need to know about all failures +- All handlers should run regardless of failures +- Collecting multiple validation errors + +```csharp +try +{ + validationEvent.Publish(request); +} +catch (AggregateException aex) +{ + foreach (var ex in aex.InnerExceptions) + { + Console.WriteLine($"Validation error: {ex.Message}"); + } +} +``` + +### Order Policies + +Control handler invocation order: + +#### RegistrationOrder (Default) + +Handlers invoked in subscription order (FIFO): + +```csharp +[Observer(typeof(Event), Order = ObserverOrderPolicy.RegistrationOrder)] +public partial class EventOccurred { } +``` + +**Characteristics:** +- Deterministic, predictable order +- Handlers invoked in the order they were subscribed +- Slightly higher memory overhead + +**Use when:** +- Order matters (e.g., validation → processing → logging) +- Debugging requires predictable behavior +- Default choice for most scenarios + +#### Undefined + +No order guarantee, potential performance benefit: + +```csharp +[Observer(typeof(Metric), Order = ObserverOrderPolicy.Undefined)] +public partial class MetricRecorded { } +``` + +**Characteristics:** +- No ordering guarantee +- May provide better performance with Concurrent threading +- Lower memory overhead + +**Use when:** +- Order doesn't matter (e.g., independent metrics collection) +- Maximum performance is needed +- Handlers are truly independent + +### Async Configuration + +Control async method generation: + +```csharp +// Generate async methods (default) +[Observer(typeof(Data), GenerateAsync = true)] +public partial class DataAvailable { } + +// Don't generate async methods +[Observer(typeof(Data), GenerateAsync = false)] +public partial class DataAvailable { } + +// Force async-only (no sync Subscribe) +[Observer(typeof(Data), ForceAsync = true)] +public partial class DataAvailable { } +``` + +## Supported Types + +The generator supports: + +| Type | Supported | Example | +|------|-----------|---------| +| `partial class` | ✅ | `public partial class Event { }` | +| `partial struct` | ✅ | `public partial struct Event { }` | +| `partial record class` | ✅ | `public partial record class Event;` | +| `partial record struct` | ✅ | `public partial record struct Event;` | +| Non-partial types | ❌ | Generates PKOBS001 error | + +## API Reference + +### Subscribe Methods + +#### Synchronous Handler + +```csharp +public IDisposable Subscribe(Action handler) +``` + +Subscribes a synchronous handler to the event. + +**Parameters:** +- `handler`: Action to invoke when events are published + +**Returns:** `IDisposable` that removes the subscription when disposed + +**Example:** +```csharp +var sub = observable.Subscribe(payload => + Console.WriteLine(payload)); +sub.Dispose(); // Unsubscribe +``` + +#### Asynchronous Handler + +```csharp +public IDisposable Subscribe(Func handler) +``` + +Subscribes an asynchronous handler to the event. + +**Parameters:** +- `handler`: Async function to invoke when events are published + +**Returns:** `IDisposable` that removes the subscription when disposed + +**Example:** +```csharp +var sub = observable.Subscribe(async payload => + await ProcessAsync(payload)); +``` + +### Publish Methods + +#### Synchronous Publish + +```csharp +public void Publish(TPayload payload) +``` + +Publishes an event to all subscribers synchronously. + +**Parameters:** +- `payload`: The event data to publish + +**Behavior:** +- Invokes synchronous handlers directly +- Invokes async handlers synchronously (fire-and-forget) +- Exception handling per configured policy + +**Example:** +```csharp +observable.Publish(new Temperature(25.0)); +``` + +#### Asynchronous Publish + +```csharp +public ValueTask PublishAsync(TPayload payload, CancellationToken cancellationToken = default) +``` + +Publishes an event to all subscribers asynchronously. + +**Parameters:** +- `payload`: The event data to publish +- `cancellationToken`: Optional cancellation token + +**Returns:** `ValueTask` that completes when all async handlers finish + +**Behavior:** +- Waits for async handlers to complete +- Synchronous handlers run on calling thread +- Exception handling per configured policy +- Honors cancellation token + +**Example:** +```csharp +await observable.PublishAsync( + new UserAction("click"), + cancellationToken); +``` + +### Optional Hooks + +#### OnSubscriberError + +```csharp +partial void OnSubscriberError(Exception ex); +``` + +Optional method for handling subscriber exceptions when using `Exceptions = ObserverExceptionPolicy.Continue`. + +**Parameters:** +- `ex`: The exception thrown by a subscriber + +**Example:** +```csharp +[Observer(typeof(Event), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class EventOccurred +{ + partial void OnSubscriberError(Exception ex) + { + Logger.LogError(ex, "Subscriber threw exception"); + Telemetry.RecordError(ex); + } +} +``` + +## Performance Considerations + +### Memory and Allocations + +- **SingleThreadedFast**: Uses `List`, minimal allocations +- **Locking**: Uses `List` with lock, snapshots on publish +- **Concurrent**: Uses `ImmutableList` (RegistrationOrder) or `ConcurrentBag` (Undefined) + +### Thread Safety Overhead + +| Policy | Subscribe/Unsubscribe | Publish | Notes | +|--------|----------------------|---------|-------| +| SingleThreadedFast | None | None | Fastest, not thread-safe | +| Locking | Lock acquisition | Snapshot + lock | Good for moderate concurrency | +| Concurrent | Atomic operations | Lock-free | Best for high concurrency | + +### Async Performance + +- `PublishAsync` uses `ValueTask` to reduce allocations +- Synchronous handlers in `PublishAsync` don't allocate +- Async handlers only allocate if they don't complete synchronously + +### Best Practices + +1. **Use Locking by default** unless you have specific needs +2. **Profile before optimizing** - start with defaults +3. **Dispose subscriptions** to prevent memory leaks +4. **Use SingleThreadedFast** only when guaranteed single-threaded +5. **Prefer Continue exception policy** for fault tolerance +6. **Use weak references** if subscribers have long lifetimes and publishers are short-lived (implement manually) + +## Common Patterns + +### Observable Properties + +```csharp +public record PropertyChanged(string PropertyName, object? NewValue); + +[Observer(typeof(PropertyChanged))] +public partial class PropertyChangeNotifier +{ +} + +public class ViewModel +{ + private readonly PropertyChangeNotifier _notifier = new(); + private string _name = ""; + + public IDisposable SubscribeToChanges(Action handler) => + _notifier.Subscribe(handler); + + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + _notifier.Publish(new PropertyChanged(nameof(Name), value)); + } + } + } +} +``` + +### Event Aggregator + +```csharp +public partial class EventAggregator +{ + [Observer(typeof(UserLoggedIn))] + private partial class UserLoggedInEvent { } + + [Observer(typeof(OrderPlaced))] + private partial class OrderPlacedEvent { } + + private readonly UserLoggedInEvent _userLoggedIn = new(); + private readonly OrderPlacedEvent _orderPlaced = new(); + + public IDisposable Subscribe(Action handler) + { + return typeof(T).Name switch + { + nameof(UserLoggedIn) => _userLoggedIn.Subscribe(e => handler((T)(object)e)), + nameof(OrderPlaced) => _orderPlaced.Subscribe(e => handler((T)(object)e)), + _ => throw new NotSupportedException() + }; + } + + public void Publish(T @event) + { + switch (@event) + { + case UserLoggedIn e: _userLoggedIn.Publish(e); break; + case OrderPlaced e: _orderPlaced.Publish(e); break; + } + } +} +``` + +### Composite Subscriptions + +```csharp +public class CompositeDisposable : IDisposable +{ + private readonly List _subscriptions = new(); + + public void Add(IDisposable subscription) => _subscriptions.Add(subscription); + + public void Dispose() + { + foreach (var sub in _subscriptions) + sub.Dispose(); + _subscriptions.Clear(); + } +} + +// Usage +var subscriptions = new CompositeDisposable(); +subscriptions.Add(tempEvent.Subscribe(HandleTemperature)); +subscriptions.Add(pressureEvent.Subscribe(HandlePressure)); +subscriptions.Add(humidityEvent.Subscribe(HandleHumidity)); + +// Unsubscribe all at once +subscriptions.Dispose(); +``` + +## Diagnostics + +| ID | Severity | Description | +|----|----------|-------------| +| **PKOBS001** | Error | Type marked with `[Observer]` must be declared as `partial` | +| **PKOBS002** | Error | Unable to extract payload type from `[Observer]` attribute | +| **PKOBS003** | Error | Invalid configuration (conflicting settings) | + +### PKOBS001: Type must be partial + +**Cause:** Missing `partial` keyword on observer type. + +**Fix:** +```csharp +// ❌ Wrong +[Observer(typeof(Message))] +public class MessageReceived { } + +// ✅ Correct +[Observer(typeof(Message))] +public partial class MessageReceived { } +``` + +### PKOBS002: Missing payload type + +**Cause:** Payload type could not be determined from attribute. + +**Fix:** Ensure you provide a valid type to the attribute: +```csharp +// ✅ Correct +[Observer(typeof(MyEventData))] +public partial class MyEvent { } +``` + +## Best Practices + +### 1. Always Dispose Subscriptions + +Prevent memory leaks by disposing subscriptions: + +```csharp +// ✅ Good: Using statement +using var subscription = observable.Subscribe(HandleEvent); + +// ✅ Good: Explicit disposal +var subscription = observable.Subscribe(HandleEvent); +// ... later ... +subscription.Dispose(); + +// ⚠️ Bad: Never disposed - memory leak! +observable.Subscribe(HandleEvent); +``` + +### 2. Use Immutable Payload Types + +Records make excellent event payloads: + +```csharp +// ✅ Good: Immutable record +public record OrderPlaced(int OrderId, decimal Amount, DateTime Timestamp); + +[Observer(typeof(OrderPlaced))] +public partial class OrderPlacedEvent { } + +// ⚠️ Avoid: Mutable payload +public class OrderPlaced +{ + public int OrderId { get; set; } // Can be modified by handlers +} +``` + +### 3. Keep Handlers Fast + +Long-running handlers block other subscribers: + +```csharp +// ⚠️ Bad: Slow handler blocks others +observable.Subscribe(data => +{ + Thread.Sleep(1000); // Blocks! + ProcessData(data); +}); + +// ✅ Good: Offload work +observable.Subscribe(data => + Task.Run(() => ProcessData(data))); + +// ✅ Better: Use async +observable.Subscribe(async data => + await ProcessDataAsync(data)); +``` + +### 4. Choose the Right Threading Policy + +```csharp +// ✅ UI thread events +[Observer(typeof(UiEvent), Threading = ObserverThreadingPolicy.SingleThreadedFast)] + +// ✅ General application events +[Observer(typeof(AppEvent), Threading = ObserverThreadingPolicy.Locking)] + +// ✅ High-throughput metrics +[Observer(typeof(Metric), Threading = ObserverThreadingPolicy.Concurrent)] +``` + +### 5. Handle Exceptions Appropriately + +```csharp +// ✅ Good: Fault tolerant +[Observer(typeof(Notification), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class NotificationSent +{ + partial void OnSubscriberError(Exception ex) + { + Logger.LogWarning(ex, "Notification handler failed"); + } +} + +// ✅ Good: Critical operations +[Observer(typeof(Payment), Exceptions = ObserverExceptionPolicy.Stop)] +public partial class PaymentProcessed { } +``` + +### 6. Use Meaningful Event Names + +```csharp +// ✅ Good: Clear, action-based names +[Observer(typeof(User))] +public partial class UserRegistered { } + +[Observer(typeof(Order))] +public partial class OrderShipped { } + +// ⚠️ Unclear +[Observer(typeof(User))] +public partial class UserEvent { } // What happened to the user? +``` + +## Examples + +See the [ObserverGeneratorDemo](/src/PatternKit.Examples/ObserverGeneratorDemo/) for complete, runnable examples including: + +- **TemperatureMonitor.cs**: Basic observer usage with temperature sensors +- **NotificationSystem.cs**: Async handlers and exception handling +- **README.md**: Example explanations and usage + +## Troubleshooting + +### Handlers not being called + +**Possible causes:** +1. Subscription was disposed +2. Wrong payload type +3. Exception thrown and swallowed (check `OnSubscriberError`) + +**Debug steps:** +```csharp +var sub = observable.Subscribe(payload => +{ + Console.WriteLine("Handler called!"); // Add logging +}); +observable.Publish(payload); +``` + +### Memory leaks + +**Cause:** Subscriptions not disposed. + +**Fix:** Always dispose subscriptions, especially in long-lived objects: +```csharp +public class Service : IDisposable +{ + private readonly CompositeDisposable _subscriptions = new(); + + public Service(SomeObservable observable) + { + _subscriptions.Add(observable.Subscribe(HandleEvent)); + } + + public void Dispose() => _subscriptions.Dispose(); +} +``` + +### Race conditions with SingleThreadedFast + +**Cause:** Using SingleThreadedFast with multiple threads. + +**Fix:** Use `Locking` or `Concurrent` policy: +```csharp +[Observer(typeof(Data), Threading = ObserverThreadingPolicy.Locking)] +public partial class DataReceived { } +``` + +### Async handlers not awaited in Publish + +**Behavior:** `Publish` calls async handlers in fire-and-forget mode. + +**Solution:** Use `PublishAsync` to await async handlers: +```csharp +// ⚠️ Async handlers not awaited +observable.Publish(data); + +// ✅ Async handlers are awaited +await observable.PublishAsync(data); +``` + +## See Also + +- [Memento Generator](memento.md) — For saving/restoring observable state +- [State Machine Generator](state-machine.md) — For state-based event handling +- [Observer Pattern (Classic)](https://en.wikipedia.org/wiki/Observer_pattern) +- [Reactive Extensions](https://reactivex.io/) — Advanced reactive programming diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index ed0dbad..16d8555 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -28,6 +28,9 @@ - name: Visitor Generator href: visitor-generator.md +- name: Observer + href: observer.md + - name: Examples href: examples.md diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs new file mode 100644 index 0000000..43b4cf8 --- /dev/null +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs @@ -0,0 +1,375 @@ +using PatternKit.Generators.Observer; + +namespace PatternKit.Examples.ObserverGeneratorDemo; + +/// +/// A notification message to be sent. +/// +/// ID of the recipient. +/// The notification message. +/// Priority level (0=low, 1=normal, 2=high). +public record Notification(string RecipientId, string Message, int Priority); + +/// +/// Result of attempting to send a notification. +/// +/// Whether the send was successful. +/// Which channel was used (Email, SMS, Push). +/// Error message if failed. +public record NotificationResult(bool Success, string Channel, string? Error = null); + +/// +/// Observable event for notifications with async support. +/// Demonstrates async handlers and PublishAsync. +/// +[Observer(typeof(Notification), + Threading = ObserverThreadingPolicy.Locking, + Exceptions = ObserverExceptionPolicy.Continue, + GenerateAsync = true)] +public partial class NotificationPublished +{ + partial void OnSubscriberError(Exception ex) + { + Console.WriteLine($"❌ Notification handler error: {ex.Message}"); + } +} + +/// +/// Observable event for notification results. +/// Uses Aggregate exception policy to collect all failures. +/// +[Observer(typeof(NotificationResult), + Threading = ObserverThreadingPolicy.Locking, + Exceptions = ObserverExceptionPolicy.Aggregate)] +public partial class NotificationSent +{ +} + +/// +/// Multi-channel notification system with async handlers. +/// +public class NotificationSystem +{ + private readonly NotificationPublished _notificationPublished = new(); + private readonly NotificationSent _notificationSent = new(); + private readonly Random _random = new(); + + /// + /// Subscribes to notifications with a synchronous handler. + /// + public IDisposable Subscribe(Action handler) => + _notificationPublished.Subscribe(handler); + + /// + /// Subscribes to notifications with an async handler. + /// + public IDisposable SubscribeAsync(Func handler) => + _notificationPublished.Subscribe(handler); + + /// + /// Subscribes to notification send results. + /// + public IDisposable OnNotificationSent(Action handler) => + _notificationSent.Subscribe(handler); + + /// + /// Sends a notification through all registered channels asynchronously. + /// + public async Task SendAsync(Notification notification, CancellationToken cancellationToken = default) + { + Console.WriteLine($"\n📤 Sending notification (Priority: {notification.Priority})..."); + await _notificationPublished.PublishAsync(notification, cancellationToken); + } + + /// + /// Reports that a notification was sent through a channel. + /// + public void ReportSent(NotificationResult result) + { + _notificationSent.Publish(result); + } + + /// + /// Simulates sending an email (async operation). + /// + public async Task SendEmailAsync(Notification notification) + { + await Task.Delay(100); // Simulate network delay + + // Simulate random failures (20% chance) + if (_random.NextDouble() < 0.2) + { + return new NotificationResult(false, "Email", "SMTP server unavailable"); + } + + Console.WriteLine($" ✉️ Email sent to {notification.RecipientId}"); + return new NotificationResult(true, "Email"); + } + + /// + /// Simulates sending an SMS (async operation). + /// + public async Task SendSmsAsync(Notification notification) + { + await Task.Delay(80); // Simulate network delay + + // High priority only + if (notification.Priority < 2) + { + return new NotificationResult(false, "SMS", "Priority too low for SMS"); + } + + Console.WriteLine($" 📱 SMS sent to {notification.RecipientId}"); + return new NotificationResult(true, "SMS"); + } + + /// + /// Simulates sending a push notification (async operation). + /// + public async Task SendPushAsync(Notification notification) + { + await Task.Delay(50); // Simulate network delay + Console.WriteLine($" 🔔 Push notification sent to {notification.RecipientId}"); + return new NotificationResult(true, "Push"); + } +} + +/// +/// Demonstrates async handlers with PublishAsync. +/// +public static class AsyncNotificationDemo +{ + public static async Task RunAsync() + { + Console.WriteLine("=== Async Notification System ===\n"); + + var system = new NotificationSystem(); + + // Subscribe email channel (async handler) + using var emailSub = system.SubscribeAsync(async notification => + { + var result = await system.SendEmailAsync(notification); + system.ReportSent(result); + }); + + // Subscribe SMS channel (async handler) + using var smsSub = system.SubscribeAsync(async notification => + { + var result = await system.SendSmsAsync(notification); + system.ReportSent(result); + }); + + // Subscribe push channel (async handler) + using var pushSub = system.SubscribeAsync(async notification => + { + var result = await system.SendPushAsync(notification); + system.ReportSent(result); + }); + + // Subscribe to results to track success/failure + var successCount = 0; + var failureCount = 0; + using var resultSub = system.OnNotificationSent(result => + { + if (result.Success) + { + successCount++; + } + else + { + failureCount++; + Console.WriteLine($" ⚠️ {result.Channel} failed: {result.Error}"); + } + }); + + // Send notifications with different priorities + var notifications = new[] + { + new Notification("user123", "Welcome to our service!", Priority: 1), + new Notification("user456", "Your order has shipped", Priority: 1), + new Notification("user789", "URGENT: Security alert", Priority: 2), + new Notification("user999", "Daily digest available", Priority: 0) + }; + + foreach (var notification in notifications) + { + await system.SendAsync(notification); + await Task.Delay(200); // Space out notifications + } + + Console.WriteLine($"\n📊 Results: {successCount} successful, {failureCount} failed"); + } +} + +/// +/// Demonstrates exception handling with different policies. +/// +public static class ExceptionHandlingDemo +{ + public static void Run() + { + Console.WriteLine("\n=== Exception Handling Demo ===\n"); + + // Demo 1: Continue policy (default) - all handlers run despite errors + Console.WriteLine("1. Continue Policy (fault-tolerant):"); + DemoContinuePolicy(); + + // Demo 2: Aggregate policy - collect all errors + Console.WriteLine("\n2. Aggregate Policy (collect all errors):"); + DemoAggregatePolicy(); + } + + private static void DemoContinuePolicy() + { + var notification = new NotificationPublished(); + + // Handler 1: Works fine + notification.Subscribe(n => + Console.WriteLine(" ✅ Handler 1: Success")); + + // Handler 2: Throws exception + notification.Subscribe(n => + { + Console.WriteLine(" ❌ Handler 2: Throwing exception..."); + throw new InvalidOperationException("Handler 2 failed"); + }); + + // Handler 3: Also works fine + notification.Subscribe(n => + Console.WriteLine(" ✅ Handler 3: Success (ran despite Handler 2 error)")); + + notification.Publish(new Notification("test", "Test message", 1)); + Console.WriteLine(" ℹ️ All handlers attempted, errors logged via OnSubscriberError"); + } + + private static void DemoAggregatePolicy() + { + var results = new NotificationSent(); + + // Handler 1: Throws + results.Subscribe(r => + { + Console.WriteLine(" ❌ Validator 1: Failed"); + throw new InvalidOperationException("Validation 1 failed"); + }); + + // Handler 2: Also throws + results.Subscribe(r => + { + Console.WriteLine(" ❌ Validator 2: Failed"); + throw new ArgumentException("Validation 2 failed"); + }); + + // Handler 3: Would succeed + results.Subscribe(r => + Console.WriteLine(" ✅ Validator 3: Success")); + + try + { + results.Publish(new NotificationResult(true, "Test")); + Console.WriteLine(" ℹ️ No exception thrown (shouldn't reach here)"); + } + catch (AggregateException ex) + { + Console.WriteLine($" 🔥 AggregateException caught with {ex.InnerExceptions.Count} errors:"); + foreach (var inner in ex.InnerExceptions) + { + Console.WriteLine($" - {inner.GetType().Name}: {inner.Message}"); + } + } + } +} + +/// +/// Demonstrates mixing sync and async handlers. +/// +public static class MixedHandlersDemo +{ + public static async Task RunAsync() + { + Console.WriteLine("\n=== Mixed Sync/Async Handlers Demo ===\n"); + + var notification = new NotificationPublished(); + + // Sync handler + notification.Subscribe(n => + Console.WriteLine($" 🔹 Sync handler: {n.Message}")); + + // Async handler + notification.Subscribe(async n => + { + await Task.Delay(50); + Console.WriteLine($" 🔸 Async handler: {n.Message}"); + }); + + // Another sync handler + notification.Subscribe(n => + Console.WriteLine($" 🔹 Sync handler 2: Priority={n.Priority}")); + + Console.WriteLine("Publishing with Publish (sync):"); + notification.Publish(new Notification("user", "Hello World", 1)); + + // Note: async handlers run fire-and-forget with Publish + await Task.Delay(100); // Wait for async handlers + + Console.WriteLine("\nPublishing with PublishAsync (awaits async handlers):"); + await notification.PublishAsync(new Notification("user", "Goodbye World", 2)); + + Console.WriteLine("\nNote: PublishAsync waits for all async handlers to complete."); + } +} + +/// +/// Demonstrates cancellation token support in async handlers. +/// +public static class CancellationDemo +{ + public static async Task RunAsync() + { + Console.WriteLine("\n=== Cancellation Demo ===\n"); + + var notification = new NotificationPublished(); + var processedCount = 0; + + // Long-running async handler + notification.Subscribe(async n => + { + Console.WriteLine(" ⏳ Starting long operation..."); + try + { + // This will be cancelled + await Task.Delay(5000); + processedCount++; + Console.WriteLine(" ✅ Long operation completed"); + } + catch (TaskCanceledException) + { + Console.WriteLine(" 🚫 Long operation cancelled"); + } + }); + + // Quick handler + notification.Subscribe(async n => + { + await Task.Delay(10); + processedCount++; + Console.WriteLine(" ✅ Quick operation completed"); + }); + + using var cts = new CancellationTokenSource(100); // Cancel after 100ms + + try + { + await notification.PublishAsync( + new Notification("user", "Test", 1), + cts.Token); + } + catch (OperationCanceledException) + { + Console.WriteLine("\n ℹ️ PublishAsync was cancelled"); + } + + Console.WriteLine($"\n Handlers completed: {processedCount}/2"); + Console.WriteLine(" (Quick handler completed, long handler was cancelled)"); + } +} diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/README.md b/src/PatternKit.Examples/ObserverGeneratorDemo/README.md new file mode 100644 index 0000000..27e3b2e --- /dev/null +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/README.md @@ -0,0 +1,288 @@ +# Observer Generator Examples + +This directory contains comprehensive examples demonstrating the Observer pattern source generator. + +## Examples Overview + +### 1. TemperatureMonitor.cs + +Demonstrates fundamental Observer pattern usage with a temperature monitoring system. + +**Key Concepts:** +- Basic `[Observer(typeof(T))]` attribute usage +- Synchronous event handling +- Multiple subscribers to the same event +- Exception handling with `OnSubscriberError` hook +- Subscription lifecycle management (Subscribe/Dispose) +- Default configuration (Locking, Continue, RegistrationOrder) + +**Demos Included:** +- `TemperatureMonitorDemo.Run()` - Complete monitoring system with alerts +- `MultipleSubscribersDemo.Run()` - Multiple handlers with fault tolerance +- `SubscriptionLifecycleDemo.Run()` - Subscription management patterns + +**Run Example:** +```csharp +TemperatureMonitorDemo.Run(); +MultipleSubscribersDemo.Run(); +SubscriptionLifecycleDemo.Run(); +``` + +### 2. NotificationSystem.cs + +Demonstrates advanced Observer features with a multi-channel notification system. + +**Key Concepts:** +- Async event handlers with `Func` +- `PublishAsync` for awaiting async handlers +- Exception policies: Continue vs Aggregate +- Mixing sync and async handlers +- Cancellation token support +- Real-world async patterns (email, SMS, push notifications) + +**Demos Included:** +- `AsyncNotificationDemo.RunAsync()` - Multi-channel async notifications +- `ExceptionHandlingDemo.Run()` - Exception policy comparison +- `MixedHandlersDemo.RunAsync()` - Sync and async handlers together +- `CancellationDemo.RunAsync()` - Cancellation token propagation + +**Run Example:** +```csharp +await AsyncNotificationDemo.RunAsync(); +ExceptionHandlingDemo.Run(); +await MixedHandlersDemo.RunAsync(); +await CancellationDemo.RunAsync(); +``` + +## Quick Start + +### Basic Usage + +```csharp +// Define your event payload +public record TemperatureReading(string SensorId, double Celsius, DateTime Timestamp); + +// Generate Observer implementation +[Observer(typeof(TemperatureReading))] +public partial class TemperatureChanged +{ +} + +// Use it +var tempEvent = new TemperatureChanged(); + +// Subscribe +var subscription = tempEvent.Subscribe(reading => +{ + Console.WriteLine($"{reading.SensorId}: {reading.Celsius}°C"); +}); + +// Publish +tempEvent.Publish(new TemperatureReading("Sensor-01", 23.5, DateTime.UtcNow)); + +// Unsubscribe +subscription.Dispose(); +``` + +### Async Usage + +```csharp +public record Notification(string Message); + +[Observer(typeof(Notification))] +public partial class NotificationSent +{ +} + +var notif = new NotificationSent(); + +// Async handler +notif.Subscribe(async n => +{ + await SendEmailAsync(n.Message); + await LogToDbAsync(n); +}); + +// Await all async handlers +await notif.PublishAsync(new Notification("Hello!")); +``` + +## Configuration Examples + +### Threading Policies + +```csharp +// Default: Thread-safe with locks +[Observer(typeof(Message), Threading = ObserverThreadingPolicy.Locking)] +public partial class MessageReceived { } + +// Single-threaded: No thread safety, maximum performance +[Observer(typeof(UiEvent), Threading = ObserverThreadingPolicy.SingleThreadedFast)] +public partial class UiEventOccurred { } + +// Concurrent: Lock-free for high throughput +[Observer(typeof(Metric), Threading = ObserverThreadingPolicy.Concurrent)] +public partial class MetricRecorded { } +``` + +### Exception Policies + +```csharp +// Continue: Fault-tolerant, all handlers run (default) +[Observer(typeof(Event), Exceptions = ObserverExceptionPolicy.Continue)] +public partial class EventOccurred +{ + partial void OnSubscriberError(Exception ex) + { + Logger.LogError(ex, "Handler failed"); + } +} + +// Stop: Fail-fast, stop on first error +[Observer(typeof(Payment), Exceptions = ObserverExceptionPolicy.Stop)] +public partial class PaymentProcessed { } + +// Aggregate: Collect all errors, throw AggregateException +[Observer(typeof(Validation), Exceptions = ObserverExceptionPolicy.Aggregate)] +public partial class ValidationRequested { } +``` + +### Order Policies + +```csharp +// RegistrationOrder: FIFO, deterministic (default) +[Observer(typeof(Event), Order = ObserverOrderPolicy.RegistrationOrder)] +public partial class EventRaised { } + +// Undefined: No order guarantee, potential performance benefit +[Observer(typeof(Metric), Order = ObserverOrderPolicy.Undefined)] +public partial class MetricCollected { } +``` + +## Common Patterns + +### 1. Observable Property + +```csharp +public record PropertyChanged(string Name, object? Value); + +[Observer(typeof(PropertyChanged))] +public partial class PropertyChangedEvent { } + +public class ViewModel +{ + private readonly PropertyChangedEvent _propertyChanged = new(); + private string _name = ""; + + public IDisposable OnPropertyChanged(Action handler) => + _propertyChanged.Subscribe(handler); + + public string Name + { + get => _name; + set + { + if (_name != value) + { + _name = value; + _propertyChanged.Publish(new PropertyChanged(nameof(Name), value)); + } + } + } +} +``` + +### 2. Event Aggregator + +```csharp +public class EventBus +{ + [Observer(typeof(UserLoggedIn))] + private partial class UserLoggedInEvent { } + + [Observer(typeof(OrderPlaced))] + private partial class OrderPlacedEvent { } + + private readonly UserLoggedInEvent _userLoggedIn = new(); + private readonly OrderPlacedEvent _orderPlaced = new(); + + public IDisposable OnUserLoggedIn(Action handler) => + _userLoggedIn.Subscribe(handler); + + public IDisposable OnOrderPlaced(Action handler) => + _orderPlaced.Subscribe(handler); + + public void Publish(UserLoggedIn e) => _userLoggedIn.Publish(e); + public void Publish(OrderPlaced e) => _orderPlaced.Publish(e); +} +``` + +### 3. Composite Subscriptions + +```csharp +public class CompositeDisposable : IDisposable +{ + private readonly List _subscriptions = new(); + + public void Add(IDisposable subscription) => _subscriptions.Add(subscription); + + public void Dispose() + { + foreach (var sub in _subscriptions) + sub.Dispose(); + _subscriptions.Clear(); + } +} + +// Usage +var subscriptions = new CompositeDisposable(); +subscriptions.Add(eventA.Subscribe(HandleA)); +subscriptions.Add(eventB.Subscribe(HandleB)); +subscriptions.Add(eventC.Subscribe(HandleC)); + +// Unsubscribe all at once +subscriptions.Dispose(); +``` + +## Running All Examples + +To run all examples in sequence: + +```csharp +public static async Task RunAllExamples() +{ + // Temperature monitoring examples + TemperatureMonitorDemo.Run(); + MultipleSubscribersDemo.Run(); + SubscriptionLifecycleDemo.Run(); + + // Notification system examples + await AsyncNotificationDemo.RunAsync(); + ExceptionHandlingDemo.Run(); + await MixedHandlersDemo.RunAsync(); + await CancellationDemo.RunAsync(); +} +``` + +## Key Takeaways + +1. **Always dispose subscriptions** - Prevents memory leaks +2. **Use immutable payload types** - Records work great +3. **Choose appropriate policies** - Default (Locking + Continue + RegistrationOrder) is good for most cases +4. **Use PublishAsync for async handlers** - `Publish` fires and forgets; `PublishAsync` awaits +5. **Handle errors gracefully** - Implement `OnSubscriberError` with Continue policy +6. **Keep handlers fast** - Offload work to background tasks if needed + +## Performance Tips + +- **SingleThreadedFast**: Use for UI thread events (20-30% faster than Locking) +- **Concurrent**: Use for high-throughput metrics (better under contention) +- **Locking**: Default choice, good balance of safety and performance +- **Undefined Order**: Slight performance benefit if order doesn't matter +- **ValueTask**: Async handlers use ValueTask for reduced allocations + +## See Also + +- [Observer Generator Documentation](/docs/generators/observer.md) +- [PatternKit.Generators API Reference](https://patternkit.dev/api) +- [Observer Pattern (Wikipedia)](https://en.wikipedia.org/wiki/Observer_pattern) diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/TemperatureMonitor.cs b/src/PatternKit.Examples/ObserverGeneratorDemo/TemperatureMonitor.cs new file mode 100644 index 0000000..f4a3a1a --- /dev/null +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/TemperatureMonitor.cs @@ -0,0 +1,244 @@ +using PatternKit.Generators.Observer; + +namespace PatternKit.Examples.ObserverGeneratorDemo; + +/// +/// Temperature reading from a sensor. +/// +/// Unique identifier of the sensor. +/// Temperature in Celsius. +/// When the reading was taken. +public record TemperatureReading(string SensorId, double Celsius, DateTime Timestamp); + +/// +/// Temperature alert when temperature exceeds threshold. +/// +/// Sensor that triggered the alert. +/// The temperature that triggered the alert. +/// The threshold that was exceeded. +public record TemperatureAlert(string SensorId, double Temperature, double Threshold); + +/// +/// Observable event for temperature readings using default configuration. +/// Threading: Locking (thread-safe) +/// Exceptions: Continue (fault-tolerant) +/// Order: RegistrationOrder (FIFO) +/// +[Observer(typeof(TemperatureReading))] +public partial class TemperatureChanged +{ + // Optional: Log errors from subscribers + partial void OnSubscriberError(Exception ex) + { + Console.WriteLine($"⚠️ Subscriber error: {ex.Message}"); + } +} + +/// +/// Observable event for temperature alerts with custom configuration. +/// Uses Stop exception policy to ensure critical alerts aren't missed. +/// +[Observer(typeof(TemperatureAlert), + Threading = ObserverThreadingPolicy.Locking, + Exceptions = ObserverExceptionPolicy.Stop, + Order = ObserverOrderPolicy.RegistrationOrder)] +public partial class TemperatureAlertRaised +{ +} + +/// +/// Temperature monitoring system that simulates sensors and raises events. +/// +public class TemperatureMonitoringSystem +{ + private readonly TemperatureChanged _temperatureChanged = new(); + private readonly TemperatureAlertRaised _alertRaised = new(); + private readonly Dictionary _thresholds = new(); + private readonly Random _random = new(); + + /// + /// Sets the alert threshold for a specific sensor. + /// + public void SetThreshold(string sensorId, double thresholdCelsius) + { + _thresholds[sensorId] = thresholdCelsius; + } + + /// + /// Subscribes to temperature change events. + /// + public IDisposable OnTemperatureChanged(Action handler) => + _temperatureChanged.Subscribe(handler); + + /// + /// Subscribes to temperature alert events. + /// + public IDisposable OnTemperatureAlert(Action handler) => + _alertRaised.Subscribe(handler); + + /// + /// Simulates a sensor reading and publishes events. + /// + public void SimulateReading(string sensorId) + { + // Generate random temperature between 15°C and 35°C + var temperature = 15 + (_random.NextDouble() * 20); + var reading = new TemperatureReading(sensorId, temperature, DateTime.UtcNow); + + // Publish temperature change + _temperatureChanged.Publish(reading); + + // Check threshold and raise alert if exceeded + if (_thresholds.TryGetValue(sensorId, out var threshold) && temperature > threshold) + { + var alert = new TemperatureAlert(sensorId, temperature, threshold); + _alertRaised.Publish(alert); + } + } +} + +/// +/// Demonstrates basic Observer pattern usage with temperature monitoring. +/// +public static class TemperatureMonitorDemo +{ + public static void Run() + { + Console.WriteLine("=== Temperature Monitoring System ===\n"); + + var system = new TemperatureMonitoringSystem(); + + // Configure thresholds + system.SetThreshold("Sensor-01", 28.0); + system.SetThreshold("Sensor-02", 25.0); + + // Subscribe to temperature changes + using var tempSubscription = system.OnTemperatureChanged(reading => + { + Console.WriteLine($"📊 {reading.SensorId}: {reading.Celsius:F1}°C at {reading.Timestamp:HH:mm:ss}"); + }); + + // Subscribe to alerts with critical handler + using var alertSubscription = system.OnTemperatureAlert(alert => + { + Console.WriteLine($"🔥 ALERT! {alert.SensorId} exceeded {alert.Threshold:F1}°C: {alert.Temperature:F1}°C"); + }); + + // Additional alert handler for logging + using var logSubscription = system.OnTemperatureAlert(alert => + { + LogToFile($"Temperature alert: {alert.SensorId} - {alert.Temperature:F1}°C"); + }); + + Console.WriteLine("Simulating sensor readings...\n"); + + // Simulate readings from multiple sensors + for (int i = 0; i < 10; i++) + { + system.SimulateReading("Sensor-01"); + system.SimulateReading("Sensor-02"); + Thread.Sleep(500); // Simulate time between readings + } + + Console.WriteLine("\n--- End of simulation ---"); + } + + private static void LogToFile(string message) + { + // In real application, write to file + Console.WriteLine($" 📝 Logged: {message}"); + } +} + +/// +/// Demonstrates multiple subscribers with different behaviors. +/// +public static class MultipleSubscribersDemo +{ + public static void Run() + { + Console.WriteLine("\n=== Multiple Subscribers Demo ===\n"); + + var temperatureChanged = new TemperatureChanged(); + + // Subscriber 1: Console display + var sub1 = temperatureChanged.Subscribe(reading => + Console.WriteLine($"Display: {reading.Celsius:F1}°C")); + + // Subscriber 2: Statistics tracker + var temperatures = new List(); + var sub2 = temperatureChanged.Subscribe(reading => + { + temperatures.Add(reading.Celsius); + var avg = temperatures.Average(); + Console.WriteLine($"Stats: Current={reading.Celsius:F1}°C, Avg={avg:F1}°C, Count={temperatures.Count}"); + }); + + // Subscriber 3: Faulty handler (demonstrates exception handling) + var sub3 = temperatureChanged.Subscribe(_ => + { + throw new InvalidOperationException("Simulated error in subscriber"); + }); + + // Subscriber 4: Continues to work despite sub3's error + var sub4 = temperatureChanged.Subscribe(reading => + Console.WriteLine($"Monitor: Temperature is {(reading.Celsius > 25 ? "HOT" : "NORMAL")}")); + + Console.WriteLine("Publishing temperature readings...\n"); + + temperatureChanged.Publish(new TemperatureReading("Test", 22.5, DateTime.UtcNow)); + Thread.Sleep(100); + temperatureChanged.Publish(new TemperatureReading("Test", 28.3, DateTime.UtcNow)); + Thread.Sleep(100); + temperatureChanged.Publish(new TemperatureReading("Test", 24.1, DateTime.UtcNow)); + + Console.WriteLine("\nNote: Subscriber 3 threw exceptions, but others continued working."); + Console.WriteLine("This is because we use ObserverExceptionPolicy.Continue (default).\n"); + + // Cleanup + sub1.Dispose(); + sub2.Dispose(); + sub3.Dispose(); + sub4.Dispose(); + } +} + +/// +/// Demonstrates subscription lifecycle management. +/// +public static class SubscriptionLifecycleDemo +{ + public static void Run() + { + Console.WriteLine("\n=== Subscription Lifecycle Demo ===\n"); + + var temperatureChanged = new TemperatureChanged(); + + Console.WriteLine("1. Creating three subscriptions..."); + var sub1 = temperatureChanged.Subscribe(r => Console.WriteLine($" Sub1: {r.Celsius:F1}°C")); + var sub2 = temperatureChanged.Subscribe(r => Console.WriteLine($" Sub2: {r.Celsius:F1}°C")); + var sub3 = temperatureChanged.Subscribe(r => Console.WriteLine($" Sub3: {r.Celsius:F1}°C")); + + Console.WriteLine("\n2. Publishing with all three active:"); + temperatureChanged.Publish(new TemperatureReading("Test", 20.0, DateTime.UtcNow)); + + Console.WriteLine("\n3. Disposing sub2..."); + sub2.Dispose(); + + Console.WriteLine("\n4. Publishing with sub1 and sub3:"); + temperatureChanged.Publish(new TemperatureReading("Test", 21.0, DateTime.UtcNow)); + + Console.WriteLine("\n5. Using 'using' for automatic disposal:"); + using (var tempSub = temperatureChanged.Subscribe(r => Console.WriteLine($" Temp: {r.Celsius:F1}°C"))) + { + temperatureChanged.Publish(new TemperatureReading("Test", 22.0, DateTime.UtcNow)); + } // tempSub automatically disposed here + + Console.WriteLine("\n6. After 'using' block (tempSub disposed):"); + temperatureChanged.Publish(new TemperatureReading("Test", 23.0, DateTime.UtcNow)); + + // Cleanup + sub1.Dispose(); + sub3.Dispose(); + } +} From f10e2ccff63ed57218ac65ae2947ba1cb91417f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 04:55:07 +0000 Subject: [PATCH 09/13] Fix parameter naming convention in NotificationSystem example Use positional parameters instead of named parameters with incorrect casing in Notification record instantiation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ObserverGeneratorDemo/NotificationSystem.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs index 43b4cf8..255a2e4 100644 --- a/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs @@ -185,10 +185,10 @@ public static async Task RunAsync() // Send notifications with different priorities var notifications = new[] { - new Notification("user123", "Welcome to our service!", Priority: 1), - new Notification("user456", "Your order has shipped", Priority: 1), - new Notification("user789", "URGENT: Security alert", Priority: 2), - new Notification("user999", "Daily digest available", Priority: 0) + new Notification("user123", "Welcome to our service!", 1), + new Notification("user456", "Your order has shipped", 1), + new Notification("user789", "URGENT: Security alert", 2), + new Notification("user999", "Daily digest available", 0) }; foreach (var notification in notifications) From 6b8e6986c01200e1719467549353939a80372205 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 05:31:56 +0000 Subject: [PATCH 10/13] Fix Observer generator: remove reflection, add nested/generic validation, fire-and-forget async, disable struct support Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../Observer/ObserverGenerator.cs | 184 +++++++++++++----- 1 file changed, 135 insertions(+), 49 deletions(-) diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs index b8c5c4c..fe81357 100644 --- a/src/PatternKit.Generators/Observer/ObserverGenerator.cs +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -67,6 +67,36 @@ private static void GenerateObserver(SourceProductionContext context, GeneratorA return; } + // Check for generic types + if (typeSymbol.IsGenericType) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidConfigRule, + syntax.Identifier.GetLocation(), + "Generic observer types are not supported")); + return; + } + + // Check for nested types + if (typeSymbol.ContainingType != null) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidConfigRule, + syntax.Identifier.GetLocation(), + "Nested observer types are not supported")); + return; + } + + // Structs have complex lifetime and capture semantics, especially with fire-and-forget async + if (typeSymbol.TypeKind == TypeKind.Struct) + { + context.ReportDiagnostic(Diagnostic.Create( + InvalidConfigRule, + syntax.Identifier.GetLocation(), + "Struct observer types are not currently supported due to capture and boxing complexity")); + return; + } + var attr = occurrence.Attributes.Length > 0 ? occurrence.Attributes[0] : null; if (attr == null || attr.ConstructorArguments.Length == 0 || attr.ConstructorArguments[0].Value is not INamedTypeSymbol payloadType) { @@ -157,12 +187,12 @@ private static string GenerateSource(INamedTypeSymbol typeSymbol, INamedTypeSymb sb.AppendLine(); } - var isStruct = typeSymbol.TypeKind == TypeKind.Struct; + var isStruct = false; // Structs are now rejected above sb.AppendLine($"{accessibility} partial {typeKind} {typeName}"); sb.AppendLine("{"); - GenerateFields(sb, config, isStruct); + GenerateFields(sb, config); GenerateSubscribeMethods(sb, payloadTypeName, config); GeneratePublishMethods(sb, payloadTypeName, config); GenerateUnsubscribeMethod(sb, config); @@ -174,47 +204,54 @@ private static string GenerateSource(INamedTypeSymbol typeSymbol, INamedTypeSymb return sb.ToString(); } - private static void GenerateFields(StringBuilder sb, ObserverConfig config, bool isStruct) + private static void GenerateFields(StringBuilder sb, ObserverConfig config) { - // For all types, use nullable fields and ensure initialization in helper methods + // Generate a shared state object to avoid any issues with subscriptions + sb.AppendLine(" private sealed class ObserverState"); + sb.AppendLine(" {"); + switch (config.Threading) { case 0: // SingleThreadedFast - sb.AppendLine(" private System.Collections.Generic.List? _subscriptions;"); - sb.AppendLine(" private int _nextId;"); + sb.AppendLine(" public System.Collections.Generic.List? Subscriptions;"); + sb.AppendLine(" public int NextId;"); break; case 1: // Locking - sb.AppendLine(" private object? _lock;"); - sb.AppendLine(" private System.Collections.Generic.List? _subscriptions;"); - sb.AppendLine(" private int _nextId;"); + sb.AppendLine(" public readonly object Lock = new();"); + sb.AppendLine(" public System.Collections.Generic.List? Subscriptions;"); + sb.AppendLine(" public int NextId;"); break; case 2: // Concurrent if (config.Order == 0) // RegistrationOrder { - sb.AppendLine(" private System.Collections.Immutable.ImmutableList? _subscriptions;"); - sb.AppendLine(" private int _nextId;"); + sb.AppendLine(" public System.Collections.Immutable.ImmutableList? Subscriptions;"); + sb.AppendLine(" public int NextId;"); } else // Undefined { - sb.AppendLine(" private System.Collections.Concurrent.ConcurrentBag? _subscriptions;"); - sb.AppendLine(" private int _nextId;"); + sb.AppendLine(" public System.Collections.Concurrent.ConcurrentBag? Subscriptions;"); + sb.AppendLine(" public int NextId;"); } break; } + + sb.AppendLine(" }"); + sb.AppendLine(); + sb.AppendLine(" private readonly ObserverState _state = new();"); sb.AppendLine(); } private static void GenerateSubscribeMethods(StringBuilder sb, string payloadType, ObserverConfig config) - { + { if (!config.ForceAsync) { sb.AppendLine($" public System.IDisposable Subscribe(System.Action<{payloadType}> handler)"); sb.AppendLine(" {"); - sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _nextId);"); - sb.AppendLine(" var sub = new Subscription(this, id, handler, false);"); - GenerateAddSubscription(sb, config, " "); + sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _state.NextId);"); + sb.AppendLine(" var sub = new Subscription(_state, id, handler, false);"); + GenerateAddSubscription(sb, config, " ", "_state"); sb.AppendLine(" return sub;"); sb.AppendLine(" }"); sb.AppendLine(); @@ -224,38 +261,38 @@ private static void GenerateSubscribeMethods(StringBuilder sb, string payloadTyp { sb.AppendLine($" public System.IDisposable Subscribe(System.Func<{payloadType}, System.Threading.Tasks.ValueTask> handler)"); sb.AppendLine(" {"); - sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _nextId);"); - sb.AppendLine(" var sub = new Subscription(this, id, handler, true);"); - GenerateAddSubscription(sb, config, " "); + sb.AppendLine(" var id = System.Threading.Interlocked.Increment(ref _state.NextId);"); + sb.AppendLine(" var sub = new Subscription(_state, id, handler, true);"); + GenerateAddSubscription(sb, config, " ", "_state"); sb.AppendLine(" return sub;"); sb.AppendLine(" }"); sb.AppendLine(); } } - private static void GenerateAddSubscription(StringBuilder sb, ObserverConfig config, string indent) + private static void GenerateAddSubscription(StringBuilder sb, ObserverConfig config, string indent, string stateVar) { switch (config.Threading) { case 0: // SingleThreadedFast - sb.AppendLine($"{indent}(_subscriptions ??= new()).Add(sub);"); + sb.AppendLine($"{indent}({stateVar}.Subscriptions ??= new()).Add(sub);"); break; case 1: // Locking - sb.AppendLine($"{indent}lock (_lock ??= new())"); + sb.AppendLine($"{indent}lock ({stateVar}.Lock)"); sb.AppendLine($"{indent}{{"); - sb.AppendLine($"{indent} (_subscriptions ??= new()).Add(sub);"); + sb.AppendLine($"{indent} ({stateVar}.Subscriptions ??= new()).Add(sub);"); sb.AppendLine($"{indent}}}"); break; case 2: // Concurrent - if (config.Order == 0) // RegistrationOrder + if (config.Order == 0) // RegistrationOrder (requires ImmutableList and Linq for .ToArray()) { - sb.AppendLine($"{indent}System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, s) => (list ?? System.Collections.Immutable.ImmutableList.Empty).Add(s), sub);"); + sb.AppendLine($"{indent}System.Collections.Immutable.ImmutableInterlocked.Update(ref {stateVar}.Subscriptions, static (list, s) => (list ?? System.Collections.Immutable.ImmutableList.Empty).Add(s), sub);"); } else // Undefined { - sb.AppendLine($"{indent}(_subscriptions ??= new()).Add(sub);"); + sb.AppendLine($"{indent}({stateVar}.Subscriptions ??= new()).Add(sub);"); } break; } @@ -278,7 +315,33 @@ private static void GeneratePublishMethods(StringBuilder sb, string payloadType, sb.AppendLine(" foreach (var sub in snapshot)"); sb.AppendLine(" {"); - sb.AppendLine(" if (sub.IsAsync) continue;"); + + // Handle async subscriptions in fire-and-forget mode + sb.AppendLine(" if (sub.IsAsync)"); + sb.AppendLine(" {"); + if (config.Exceptions == 0) // Continue - fire and forget with error handling + { + sb.AppendLine(" System.Threading.Tasks.Task.Run(async () =>"); + sb.AppendLine(" {"); + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, System.Threading.CancellationToken.None).ConfigureAwait(false);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + sb.AppendLine(" OnSubscriberError(ex);"); + sb.AppendLine(" }"); + sb.AppendLine(" });"); + } + else // Stop or Aggregate - fire and forget without error handling (exceptions logged but not propagated) + { + sb.AppendLine(" System.Threading.Tasks.Task.Run(async () =>"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, System.Threading.CancellationToken.None).ConfigureAwait(false);"); + sb.AppendLine(" });"); + } + sb.AppendLine(" continue;"); + sb.AppendLine(" }"); if (config.Exceptions == 1) // Stop { @@ -371,30 +434,29 @@ private static void GeneratePublishMethods(StringBuilder sb, string payloadType, } private static void GenerateSnapshot(StringBuilder sb, ObserverConfig config, string indent) - { + { switch (config.Threading) { case 0: // SingleThreadedFast - sb.AppendLine($"{indent}var snapshot = _subscriptions?.ToArray() ?? System.Array.Empty();"); + sb.AppendLine($"{indent}var snapshot = _state.Subscriptions?.ToArray() ?? System.Array.Empty();"); break; case 1: // Locking sb.AppendLine($"{indent}Subscription[] snapshot;"); - sb.AppendLine($"{indent}var lockObj = _lock ??= new object();"); - sb.AppendLine($"{indent}lock (lockObj)"); + sb.AppendLine($"{indent}lock (_state.Lock)"); sb.AppendLine($"{indent}{{"); - sb.AppendLine($"{indent} snapshot = _subscriptions?.ToArray() ?? System.Array.Empty();"); + sb.AppendLine($"{indent} snapshot = _state.Subscriptions?.ToArray() ?? System.Array.Empty();"); sb.AppendLine($"{indent}}}"); break; case 2: // Concurrent if (config.Order == 0) // RegistrationOrder { - sb.AppendLine($"{indent}var snapshot = System.Threading.Volatile.Read(ref _subscriptions)?.ToArray() ?? System.Array.Empty();"); + sb.AppendLine($"{indent}var snapshot = System.Threading.Volatile.Read(ref _state.Subscriptions)?.ToArray() ?? System.Array.Empty();"); } else // Undefined { - sb.AppendLine($"{indent}var snapshot = _subscriptions?.ToArray() ?? System.Array.Empty();"); + sb.AppendLine($"{indent}var snapshot = _state.Subscriptions?.ToArray() ?? System.Array.Empty();"); } break; } @@ -408,21 +470,20 @@ private static void GenerateUnsubscribeMethod(StringBuilder sb, ObserverConfig c switch (config.Threading) { case 0: // SingleThreadedFast - sb.AppendLine(" _subscriptions?.RemoveAll(s => s.Id == id);"); + sb.AppendLine(" _state.Subscriptions?.RemoveAll(s => s.Id == id);"); break; case 1: // Locking - sb.AppendLine(" var lockObj = _lock ??= new object();"); - sb.AppendLine(" lock (lockObj)"); + sb.AppendLine(" lock (_state.Lock)"); sb.AppendLine(" {"); - sb.AppendLine(" _subscriptions?.RemoveAll(s => s.Id == id);"); + sb.AppendLine(" _state.Subscriptions?.RemoveAll(s => s.Id == id);"); sb.AppendLine(" }"); break; case 2: // Concurrent if (config.Order == 0) // RegistrationOrder { - sb.AppendLine(" System.Collections.Immutable.ImmutableInterlocked.Update(ref _subscriptions, static (list, id) => list?.RemoveAll(s => s.Id == id) ?? list, id);"); + sb.AppendLine(" System.Collections.Immutable.ImmutableInterlocked.Update(ref _state.Subscriptions, static (list, id) => list?.RemoveAll(s => s.Id == id) ?? list, id);"); } else // Undefined - ConcurrentBag doesn't support efficient removal { @@ -443,10 +504,10 @@ private static void GenerateOnErrorHook(StringBuilder sb) private static void GenerateSubscriptionClass(StringBuilder sb, string payloadType, ObserverConfig config) { - // Use generic parent reference to avoid dynamic + // Subscription now uses a delegate callback instead of reflection sb.AppendLine($" private sealed class Subscription : System.IDisposable"); sb.AppendLine(" {"); - sb.AppendLine(" private object? _parent;"); + sb.AppendLine(" private ObserverState? _state;"); sb.AppendLine(" private readonly int _id;"); sb.AppendLine(" private readonly object _handler;"); sb.AppendLine(" private readonly bool _isAsync;"); @@ -455,9 +516,9 @@ private static void GenerateSubscriptionClass(StringBuilder sb, string payloadTy sb.AppendLine(" public int Id => _id;"); sb.AppendLine(" public bool IsAsync => _isAsync;"); sb.AppendLine(); - sb.AppendLine(" public Subscription(object parent, int id, object handler, bool isAsync)"); + sb.AppendLine(" public Subscription(ObserverState state, int id, object handler, bool isAsync)"); sb.AppendLine(" {"); - sb.AppendLine(" _parent = parent;"); + sb.AppendLine(" _state = state;"); sb.AppendLine(" _id = id;"); sb.AppendLine(" _handler = handler;"); sb.AppendLine(" _isAsync = isAsync;"); @@ -484,11 +545,36 @@ private static void GenerateSubscriptionClass(StringBuilder sb, string payloadTy sb.AppendLine(" public void Dispose()"); sb.AppendLine(" {"); sb.AppendLine(" if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return;"); - sb.AppendLine(" // Use reflection to call Unsubscribe since parent type is generic"); - sb.AppendLine(" var parent = System.Threading.Interlocked.Exchange(ref _parent, null);"); - sb.AppendLine(" if (parent == null) return;"); - sb.AppendLine(" var method = parent.GetType().GetMethod(\"Unsubscribe\", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);"); - sb.AppendLine(" method?.Invoke(parent, new object[] { _id });"); + sb.AppendLine(" var state = System.Threading.Interlocked.Exchange(ref _state, null);"); + sb.AppendLine(" if (state == null) return;"); + sb.AppendLine(); + + // Generate the appropriate unsubscribe logic based on threading policy + switch (config.Threading) + { + case 0: // SingleThreadedFast + sb.AppendLine(" state.Subscriptions?.RemoveAll(s => s.Id == _id);"); + break; + + case 1: // Locking + sb.AppendLine(" lock (state.Lock)"); + sb.AppendLine(" {"); + sb.AppendLine(" state.Subscriptions?.RemoveAll(s => s.Id == _id);"); + sb.AppendLine(" }"); + break; + + case 2: // Concurrent + if (config.Order == 0) // RegistrationOrder + { + sb.AppendLine(" System.Collections.Immutable.ImmutableInterlocked.Update(ref state.Subscriptions, static (list, id) => list?.RemoveAll(s => s.Id == id) ?? list, _id);"); + } + else + { + sb.AppendLine(" // ConcurrentBag doesn't support removal efficiently"); + } + break; + } + sb.AppendLine(" }"); sb.AppendLine(" }"); } From 62d3e7805f7281129a3c3cfe7614fbda835d7b3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 05:34:40 +0000 Subject: [PATCH 11/13] Update docs/examples/tests: fix diagnostics, cancellation demo, add mixed handler test, verify struct diagnostics Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/observer.md | 2 +- .../NotificationSystem.cs | 21 ++-- .../Observer/ObserverAttribute.cs | 2 +- .../ObserverGeneratorTests.cs | 107 +++++++++++------- 4 files changed, 74 insertions(+), 58 deletions(-) diff --git a/docs/generators/observer.md b/docs/generators/observer.md index f9d702e..d3aa47f 100644 --- a/docs/generators/observer.md +++ b/docs/generators/observer.md @@ -610,7 +610,7 @@ subscriptions.Dispose(); |----|----------|-------------| | **PKOBS001** | Error | Type marked with `[Observer]` must be declared as `partial` | | **PKOBS002** | Error | Unable to extract payload type from `[Observer]` attribute | -| **PKOBS003** | Error | Invalid configuration (conflicting settings) | +| **PKOBS003** | Warning | Invalid configuration or unsupported type (generic, nested, or struct types) | ### PKOBS001: Type must be partial diff --git a/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs index 255a2e4..45bfbe8 100644 --- a/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs +++ b/src/PatternKit.Examples/ObserverGeneratorDemo/NotificationSystem.cs @@ -331,21 +331,14 @@ public static async Task RunAsync() var notification = new NotificationPublished(); var processedCount = 0; - // Long-running async handler + // Long-running async handler + // Note: Cancellation is checked between handlers, not during handler execution notification.Subscribe(async n => { Console.WriteLine(" ⏳ Starting long operation..."); - try - { - // This will be cancelled - await Task.Delay(5000); - processedCount++; - Console.WriteLine(" ✅ Long operation completed"); - } - catch (TaskCanceledException) - { - Console.WriteLine(" 🚫 Long operation cancelled"); - } + await Task.Delay(100); // Shorter delay for demo + processedCount++; + Console.WriteLine(" ✅ Long operation completed"); }); // Quick handler @@ -356,7 +349,7 @@ public static async Task RunAsync() Console.WriteLine(" ✅ Quick operation completed"); }); - using var cts = new CancellationTokenSource(100); // Cancel after 100ms + using var cts = new CancellationTokenSource(50); // Cancel after 50ms - before first handler completes try { @@ -370,6 +363,6 @@ await notification.PublishAsync( } Console.WriteLine($"\n Handlers completed: {processedCount}/2"); - Console.WriteLine(" (Quick handler completed, long handler was cancelled)"); + Console.WriteLine(" Note: Cancellation is checked between handler invocations, not during execution"); } } diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs index 7d40bdc..8de6241 100644 --- a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs @@ -54,7 +54,7 @@ public ObserverAttribute(Type payloadType) /// /// Gets or sets whether to generate async publish methods. - /// When not explicitly set, async methods are generated if any async handlers are detected. + /// Default is true (async methods are always generated). /// public bool GenerateAsync { get; set; } = true; diff --git a/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs b/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs index e2fc4b8..c84a998 100644 --- a/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs +++ b/test/PatternKit.Generators.Tests/ObserverGeneratorTests.cs @@ -474,7 +474,7 @@ public static string Run() } [Fact] - public void Supports_Struct_Type() + public void Struct_Types_Are_Not_Supported() { var user = """ using PatternKit.Generators.Observer; @@ -487,46 +487,18 @@ public record Temperature(double Celsius); public partial struct TemperatureChanged { } - - public static class Demo - { - public static string Run() - { - var log = new System.Collections.Generic.List(); - var evt = new TemperatureChanged(); - - evt.Subscribe((Temperature t) => log.Add($"T:{t.Celsius}")); - evt.Publish(new Temperature(99)); - - return string.Join("|", log); - } - } """; var comp = RoslynTestHelpers.CreateCompilation( user, - assemblyName: nameof(Supports_Struct_Type)); + assemblyName: nameof(Struct_Types_Are_Not_Supported)); var gen = new Observer.ObserverGenerator(); - _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); - - using var pe = new MemoryStream(); - updated.Emit(pe); - pe.Position = 0; + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); - var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); - try - { - var asm = alc.LoadFromStream(pe); - var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); - var runMethod = demoType!.GetMethod("Run"); - var result = (string)runMethod!.Invoke(null, null)!; - Assert.Equal("T:99", result); - } - finally - { - alc.Unload(); - } + // Should report PKOBS003 diagnostic for struct types + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKOBS003" && d.GetMessage().Contains("Struct observer types are not currently supported")); } [Fact] @@ -571,7 +543,7 @@ public static string Run() } [Fact] - public void Supports_Record_Struct() + public void Record_Struct_Types_Are_Not_Supported() { var user = """ using PatternKit.Generators.Observer; @@ -584,30 +556,81 @@ public record Temperature(double Celsius); public partial record struct TemperatureChanged { } + """; + + var comp = RoslynTestHelpers.CreateCompilation( + user, + assemblyName: nameof(Record_Struct_Types_Are_Not_Supported)); + + var gen = new Observer.ObserverGenerator(); + _ = RoslynTestHelpers.Run(comp, gen, out var run, out _); + + // Should report PKOBS003 diagnostic for record struct types + var diagnostics = run.Results.SelectMany(r => r.Diagnostics).ToArray(); + Assert.Contains(diagnostics, d => d.Id == "PKOBS003" && d.GetMessage().Contains("Struct observer types are not currently supported")); + } + + [Fact] + public void Mixed_Sync_And_Async_Handlers_Both_Invoked() + { + var user = SimpleObserver + """ public static class Demo { - public static string Run() + public static async System.Threading.Tasks.Task Run() { var log = new System.Collections.Generic.List(); var evt = new TemperatureChanged(); - evt.Subscribe((Temperature t) => log.Add("OK")); - evt.Publish(new Temperature(0)); + // Subscribe sync handler + evt.Subscribe((Temperature t) => log.Add("Sync")); - return string.Join("", log); + // Subscribe async handler + evt.Subscribe(async (Temperature t) => + { + await System.Threading.Tasks.Task.Delay(1); + log.Add("Async"); + }); + + // Sync Publish should invoke async handlers fire-and-forget + evt.Publish(new Temperature(10)); + + // Wait a bit for fire-and-forget to complete + await System.Threading.Tasks.Task.Delay(50); + + return string.Join("|", log); } } """; var comp = RoslynTestHelpers.CreateCompilation( user, - assemblyName: nameof(Supports_Record_Struct)); + assemblyName: nameof(Mixed_Sync_And_Async_Handlers_Both_Invoked)); var gen = new Observer.ObserverGenerator(); _ = RoslynTestHelpers.Run(comp, gen, out _, out var updated); - var emit = updated.Emit(Stream.Null); - Assert.True(emit.Success, string.Join("\n", emit.Diagnostics)); + using var pe = new MemoryStream(); + updated.Emit(pe); + pe.Position = 0; + + var alc = new AssemblyLoadContext("ObserverTest", isCollectible: true); + try + { + var asm = alc.LoadFromStream(pe); + var demoType = asm.GetType("PatternKit.Examples.Generators.Demo"); + var runMethod = demoType!.GetMethod("Run"); + var task = (System.Threading.Tasks.Task)runMethod!.Invoke(null, null)!; + task.Wait(); + var result = task.Result; + + // Both handlers should have been invoked + Assert.Contains("Sync", result); + Assert.Contains("Async", result); + } + finally + { + alc.Unload(); + } } } From 47f52c3d43d7af999ad65bce39ef0cb15e50adbe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:51:28 +0000 Subject: [PATCH 12/13] Fix review feedback: update struct support docs, remove AttributeTargets.Struct, improve async error handling, remove unused using Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- docs/generators/observer.md | 10 +++--- .../Observer/ObserverAttribute.cs | 4 +-- .../Observer/ObserverGenerator.cs | 31 ++++++++++++------- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/generators/observer.md b/docs/generators/observer.md index d3aa47f..439c7ac 100644 --- a/docs/generators/observer.md +++ b/docs/generators/observer.md @@ -349,14 +349,16 @@ public partial class DataAvailable { } The generator supports: -| Type | Supported | Example | -|------|-----------|---------| +| Type | Supported | Example / Notes | +|------|-----------|------------------| | `partial class` | ✅ | `public partial class Event { }` | -| `partial struct` | ✅ | `public partial struct Event { }` | +| `partial struct` | ❌ | Generates PKOBS003 diagnostic (struct observers are not supported) | | `partial record class` | ✅ | `public partial record class Event;` | -| `partial record struct` | ✅ | `public partial record struct Event;` | +| `partial record struct` | ❌ | Generates PKOBS003 diagnostic (struct observers are not supported) | | Non-partial types | ❌ | Generates PKOBS001 error | +> **Note:** Struct-based observer types (`partial struct`, `partial record struct`) are rejected with PKOBS003 diagnostic. Supporting struct observers would require complex capture and boxing semantics, so only class-based observer types are currently supported. + ## API Reference ### Subscribe Methods diff --git a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs index 8de6241..31eae07 100644 --- a/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs +++ b/src/PatternKit.Generators.Abstractions/Observer/ObserverAttribute.cs @@ -2,7 +2,7 @@ namespace PatternKit.Generators.Observer; /// /// Marks a type for Observer pattern code generation. -/// The type must be declared as partial (class, struct, record class, or record struct). +/// The type must be declared as partial class or partial record class. /// /// /// @@ -17,7 +17,7 @@ namespace PatternKit.Generators.Observer; /// /// /// -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] public sealed class ObserverAttribute : Attribute { /// diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs index fe81357..a3ad4c2 100644 --- a/src/PatternKit.Generators/Observer/ObserverGenerator.cs +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -173,13 +173,6 @@ private static string GenerateSource(INamedTypeSymbol typeSymbol, INamedTypeSymb sb.AppendLine("#nullable enable"); sb.AppendLine("// "); sb.AppendLine(); - - // Add necessary using directives - if (config.Threading == 2 && config.Order == 0) // Concurrent + RegistrationOrder uses ImmutableList - { - sb.AppendLine("using System.Linq;"); - sb.AppendLine(); - } if (ns != null) { @@ -187,8 +180,6 @@ private static string GenerateSource(INamedTypeSymbol typeSymbol, INamedTypeSymb sb.AppendLine(); } - var isStruct = false; // Structs are now rejected above - sb.AppendLine($"{accessibility} partial {typeKind} {typeName}"); sb.AppendLine("{"); @@ -333,13 +324,29 @@ private static void GeneratePublishMethods(StringBuilder sb, string payloadType, sb.AppendLine(" }"); sb.AppendLine(" });"); } - else // Stop or Aggregate - fire and forget without error handling (exceptions logged but not propagated) + else if (config.Exceptions == 1) // Stop - fire and forget; exceptions are unobserved { + sb.AppendLine(" // Fire-and-forget: exceptions from async handlers cannot stop sync execution"); sb.AppendLine(" System.Threading.Tasks.Task.Run(async () =>"); sb.AppendLine(" {"); sb.AppendLine(" await sub.InvokeAsync(payload, System.Threading.CancellationToken.None).ConfigureAwait(false);"); sb.AppendLine(" });"); } + else // Aggregate - fire and forget with error logging via OnSubscriberError + { + sb.AppendLine(" // Fire-and-forget: async exceptions logged via OnSubscriberError (cannot aggregate synchronously)"); + sb.AppendLine(" System.Threading.Tasks.Task.Run(async () =>"); + sb.AppendLine(" {"); + sb.AppendLine(" try"); + sb.AppendLine(" {"); + sb.AppendLine(" await sub.InvokeAsync(payload, System.Threading.CancellationToken.None).ConfigureAwait(false);"); + sb.AppendLine(" }"); + sb.AppendLine(" catch (System.Exception ex)"); + sb.AppendLine(" {"); + sb.AppendLine(" OnSubscriberError(ex);"); + sb.AppendLine(" }"); + sb.AppendLine(" });"); + } sb.AppendLine(" continue;"); sb.AppendLine(" }"); @@ -570,7 +577,9 @@ private static void GenerateSubscriptionClass(StringBuilder sb, string payloadTy } else { - sb.AppendLine(" // ConcurrentBag doesn't support removal efficiently"); + sb.AppendLine(" // ConcurrentBag doesn't support efficient removal."); + sb.AppendLine(" // Disposed subscriptions remain in the bag but are marked as disposed and won't be invoked."); + sb.AppendLine(" // Note: This can cause memory growth if many subscriptions are created and disposed."); } break; } From aafc0c0252b8cf8909530745451a6aebc2c89afb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:44:00 +0000 Subject: [PATCH 13/13] Remove unused GenerateUnsubscribeMethod - disposal handled directly by Subscription.Dispose Co-authored-by: JerrettDavis <2610199+JerrettDavis@users.noreply.github.com> --- .../Observer/ObserverGenerator.cs | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/src/PatternKit.Generators/Observer/ObserverGenerator.cs b/src/PatternKit.Generators/Observer/ObserverGenerator.cs index a3ad4c2..3edb4a7 100644 --- a/src/PatternKit.Generators/Observer/ObserverGenerator.cs +++ b/src/PatternKit.Generators/Observer/ObserverGenerator.cs @@ -186,7 +186,6 @@ private static string GenerateSource(INamedTypeSymbol typeSymbol, INamedTypeSymb GenerateFields(sb, config); GenerateSubscribeMethods(sb, payloadTypeName, config); GeneratePublishMethods(sb, payloadTypeName, config); - GenerateUnsubscribeMethod(sb, config); GenerateOnErrorHook(sb); GenerateSubscriptionClass(sb, payloadTypeName, config); @@ -469,40 +468,6 @@ private static void GenerateSnapshot(StringBuilder sb, ObserverConfig config, st } } - private static void GenerateUnsubscribeMethod(StringBuilder sb, ObserverConfig config) - { - sb.AppendLine(" private void Unsubscribe(int id)"); - sb.AppendLine(" {"); - - switch (config.Threading) - { - case 0: // SingleThreadedFast - sb.AppendLine(" _state.Subscriptions?.RemoveAll(s => s.Id == id);"); - break; - - case 1: // Locking - sb.AppendLine(" lock (_state.Lock)"); - sb.AppendLine(" {"); - sb.AppendLine(" _state.Subscriptions?.RemoveAll(s => s.Id == id);"); - sb.AppendLine(" }"); - break; - - case 2: // Concurrent - if (config.Order == 0) // RegistrationOrder - { - sb.AppendLine(" System.Collections.Immutable.ImmutableInterlocked.Update(ref _state.Subscriptions, static (list, id) => list?.RemoveAll(s => s.Id == id) ?? list, id);"); - } - else // Undefined - ConcurrentBag doesn't support efficient removal - { - sb.AppendLine(" // ConcurrentBag doesn't support removal; subscription marks itself disposed"); - } - break; - } - - sb.AppendLine(" }"); - sb.AppendLine(); - } - private static void GenerateOnErrorHook(StringBuilder sb) { sb.AppendLine(" partial void OnSubscriberError(System.Exception ex);");