From 846fd1a9a95951bc48ffe08d2c411dfe9ff6fd69 Mon Sep 17 00:00:00 2001 From: ancplua Date: Sat, 9 May 2026 06:33:00 +0200 Subject: [PATCH 1/2] refactor: extract cancellation-token analyzer contract into shared project --- ANcpLua.Roslyn.Utilities.slnx | 3 +- ....Examples.XunitCancellationAnalyzer.csproj | 1 + .../MissingCancellationTokenAnalyzer.cs | 29 +++------------ ...Examples.XunitCancellationCodeFixes.csproj | 1 + .../MissingCancellationTokenFixer.cs | 12 ++---- ...es.Examples.XunitCancellationShared.csproj | 19 ++++++++++ .../MissingCancellationTokenContract.cs | 37 +++++++++++++++++++ 7 files changed, 70 insertions(+), 32 deletions(-) create mode 100644 src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared.csproj create mode 100644 src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/MissingCancellationTokenContract.cs diff --git a/ANcpLua.Roslyn.Utilities.slnx b/ANcpLua.Roslyn.Utilities.slnx index fe0e2ef..17549ef 100644 --- a/ANcpLua.Roslyn.Utilities.slnx +++ b/ANcpLua.Roslyn.Utilities.slnx @@ -21,6 +21,7 @@ + @@ -28,4 +29,4 @@ - \ No newline at end of file + diff --git a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer.csproj b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer.csproj index 326f476..bd52316 100644 --- a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer.csproj +++ b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer.csproj @@ -16,5 +16,6 @@ + diff --git a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/MissingCancellationTokenAnalyzer.cs b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/MissingCancellationTokenAnalyzer.cs index b8f58b7..dd2fc67 100644 --- a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/MissingCancellationTokenAnalyzer.cs +++ b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer/MissingCancellationTokenAnalyzer.cs @@ -2,6 +2,7 @@ using System.Linq; using ANcpLua.Roslyn.Utilities; using ANcpLua.Roslyn.Utilities.Analyzers; +using ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; @@ -12,7 +13,7 @@ namespace ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer; [DiagnosticAnalyzer(LanguageNames.CSharp)] public sealed class MissingCancellationTokenAnalyzer : DiagnosticAnalyzerBase { - public const string DiagnosticId = "ANCP0001"; + public const string DiagnosticId = MissingCancellationTokenContract.DiagnosticId; private static readonly DiagnosticDescriptor s_rule = new( id: DiagnosticId, @@ -61,9 +62,9 @@ private static void AnalyzeInvocation(OperationAnalysisContext context) return; var properties = ImmutableDictionary.Empty - .Add(MissingCancellationTokenDiagnosticProperties.ParameterName, parameter.Name) + .Add(MissingCancellationTokenContract.ParameterNameProperty, parameter.Name) .Add( - MissingCancellationTokenDiagnosticProperties.ParameterIndex, + MissingCancellationTokenContract.ParameterIndexProperty, parameter.Ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); context.ReportDiagnostic( @@ -80,24 +81,6 @@ private static bool IsXunitTestMethod(IMethodSymbol method) || method.HasAttribute("Xunit.TheoryAttribute"); } - private static bool IsDefaultLike(IOperation operation, ITypeSymbol cancellationTokenType) - { - operation = operation.UnwrapAllConversions().UnwrapParenthesized(); - - if (operation.Syntax.IsKind(SyntaxKind.DefaultExpression) - || operation.Syntax.IsKind(SyntaxKind.DefaultLiteralExpression)) - return true; - - return operation is IPropertyReferenceOperation - { - Property: { Name: "None", ContainingType: { } containingType } - } - && containingType.IsEqualTo(cancellationTokenType); - } -} - -internal static class MissingCancellationTokenDiagnosticProperties -{ - public const string ParameterName = nameof(ParameterName); - public const string ParameterIndex = nameof(ParameterIndex); + private static bool IsDefaultLike(IOperation operation, ITypeSymbol cancellationTokenType) => + MissingCancellationTokenContract.IsDefaultCancellationTokenArgument(operation, cancellationTokenType); } diff --git a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes.csproj b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes.csproj index 503b458..54b8d98 100644 --- a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes.csproj +++ b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes.csproj @@ -16,5 +16,6 @@ + diff --git a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/MissingCancellationTokenFixer.cs b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/MissingCancellationTokenFixer.cs index c27fa40..bc1b289 100644 --- a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/MissingCancellationTokenFixer.cs +++ b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationCodeFixes/MissingCancellationTokenFixer.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using ANcpLua.Roslyn.Utilities.Examples.XunitCancellationAnalyzer; +using ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CodeActions; using Microsoft.CodeAnalysis.CodeFixes; @@ -48,15 +49,15 @@ private static async Task ApplyAsync( Diagnostic diagnostic, CancellationToken cancellationToken) { - if (!diagnostic.Properties.TryGetValue(DiagnosticPropertyNames.ParameterName, out var parameterName) + if (!diagnostic.Properties.TryGetValue(MissingCancellationTokenContract.ParameterNameProperty, out var parameterName) || string.IsNullOrWhiteSpace(parameterName) - || !diagnostic.Properties.TryGetValue(DiagnosticPropertyNames.ParameterIndex, out var parameterIndexText) + || !diagnostic.Properties.TryGetValue(MissingCancellationTokenContract.ParameterIndexProperty, out var parameterIndexText) || !int.TryParse(parameterIndexText, out var parameterIndex)) return document; var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); var arguments = invocation.ArgumentList.Arguments.ToList(); - var tokenExpression = ParseExpression("TestContext.Current.CancellationToken"); + var tokenExpression = MissingCancellationTokenContract.CreateReplacementTokenExpression(); var safeParameterName = parameterName ?? string.Empty; var argumentIndex = arguments.FindIndex(argument => @@ -87,9 +88,4 @@ private static async Task ApplyAsync( return editor.GetChangedDocument(); } - private static class DiagnosticPropertyNames - { - public const string ParameterName = nameof(ParameterName); - public const string ParameterIndex = nameof(ParameterIndex); - } } diff --git a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared.csproj b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared.csproj new file mode 100644 index 0000000..45afba0 --- /dev/null +++ b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared.csproj @@ -0,0 +1,19 @@ + + + netstandard2.0 + latest + enable + disable + ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared + false + false + + + + + + + + + + diff --git a/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/MissingCancellationTokenContract.cs b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/MissingCancellationTokenContract.cs new file mode 100644 index 0000000..26531b9 --- /dev/null +++ b/src/ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared/MissingCancellationTokenContract.cs @@ -0,0 +1,37 @@ +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Operations; +using ANcpLua.Roslyn.Utilities; + +namespace ANcpLua.Roslyn.Utilities.Examples.XunitCancellationShared; + +public static class MissingCancellationTokenContract +{ + public const string DiagnosticId = "ANCP0001"; + public const string ParameterNameProperty = "ParameterName"; + public const string ParameterIndexProperty = "ParameterIndex"; + public const string ReplacementExpression = "TestContext.Current.CancellationToken"; + + public static bool IsDefaultCancellationTokenArgument(IOperation operation, ITypeSymbol cancellationTokenType) + { + if (operation is null) + return false; + + operation = operation.UnwrapAllConversions().UnwrapParenthesized(); + + if (operation.Syntax.IsKind(SyntaxKind.DefaultExpression) + || operation.Syntax.IsKind(SyntaxKind.DefaultLiteralExpression)) + return true; + + return operation is IPropertyReferenceOperation + { + Property: { Name: nameof(CancellationToken.None), ContainingType: { } containingType } + } && containingType.IsEqualTo(cancellationTokenType); + } + + public static ExpressionSyntax CreateReplacementTokenExpression() => + SyntaxFactory.ParseExpression(ReplacementExpression); + +} From 091d8f5f06627876b7e2d53d615dd45f61b8013b Mon Sep 17 00:00:00 2001 From: ancplua Date: Sat, 9 May 2026 06:53:22 +0200 Subject: [PATCH 2/2] fix: preserve cancellation and cache stability in incremental helpers --- .../IncrementalValuesProviderExtensions.cs | 60 +++--- .../Models/GeneratorErrorInfo.cs | 52 +++++ .../SourceProductionContextExtensions.cs | 32 +++ ...ncrementalValuesProviderExtensionsTests.cs | 183 ++++++++++++++++++ 4 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 src/ANcpLua.Roslyn.Utilities/Models/GeneratorErrorInfo.cs create mode 100644 tests/ANcpLua.Roslyn.Utilities.Testing.Tests/IncrementalValuesProviderExtensionsTests.cs diff --git a/src/ANcpLua.Roslyn.Utilities/IncrementalValuesProviderExtensions.cs b/src/ANcpLua.Roslyn.Utilities/IncrementalValuesProviderExtensions.cs index 5926e94..5a47b54 100644 --- a/src/ANcpLua.Roslyn.Utilities/IncrementalValuesProviderExtensions.cs +++ b/src/ANcpLua.Roslyn.Utilities/IncrementalValuesProviderExtensions.cs @@ -278,30 +278,37 @@ public static IncrementalValueProvider SelectAndReportExceptions + .Select((value, cancellationToken) => { cancellationToken.ThrowIfCancellationRequested(); try { - return (Value: selector(value, cancellationToken), Exception: null); + return (selector(value, cancellationToken), null); + } + catch (OperationCanceledException) + { + // Cancellation must propagate so Roslyn can abort the pipeline. + throw; } catch (Exception exception) { - return (Value: default(TResult), Exception: (Exception?)exception); + return (default, GeneratorErrorInfo.From(exception)); } }); initializationContext.RegisterSourceOutput(outputWithErrors, (context, tuple) => { - if (tuple.Exception is not null) - context.ReportException(id, tuple.Exception); + if (tuple.Error is { } error) + context.ReportException(id, error); }); - return outputWithErrors - .Select(static (x, _) => - x.Value ?? throw new InvalidOperationException("Unexpected null value in SelectAndReportExceptions")); + // The diagnostic registered above is the visible failure for the user. Downstream + // consumers receive the selector's value (or default(TResult) on failure) and decide + // how to react — record-struct sentinels like FileWithName.Empty, for instance, are + // already filtered by AddSource. Throwing here would crash the generator a second time. + return outputWithErrors.Select(static (x, _) => x.Value!); } /// @@ -450,35 +457,36 @@ public static IncrementalValuesProvider SelectAndReportExceptions + .Select((value, cancellationToken) => { cancellationToken.ThrowIfCancellationRequested(); try { - return (Value: selector(value, cancellationToken), Exception: null); + return (selector(value, cancellationToken), null); + } + catch (OperationCanceledException) + { + // Cancellation must propagate so Roslyn can abort the pipeline. + throw; } catch (Exception exception) { - return (Value: default(TResult), Exception: (Exception?)exception); + return (default, GeneratorErrorInfo.From(exception)); } }); initializationContext.RegisterSourceOutput(outputWithErrors - .Where(static x => x.Exception is not null), + .Where(static x => x.Error is not null), (context, tuple) => { - context.ReportException(id, - tuple.Exception ?? - throw new InvalidOperationException( - "Unexpected null exception in SelectAndReportExceptions")); + if (tuple.Error is { } error) + context.ReportException(id, error); }); return outputWithErrors - .Where(static x => x.Exception is null) - .Select(static (x, _) => x.Value ?? - throw new InvalidOperationException( - "Unexpected null value in SelectAndReportExceptions")); + .Where(static x => x.Error is null) + .Select(static (x, _) => x.Value!); } /// @@ -519,7 +527,12 @@ public static IncrementalValuesProvider SelectAndReportExceptions.Default; return source.Collect().SelectMany((values, _) => { + // Preserve key-insertion order so generator output is deterministic. + // Dictionary<,> enumeration order is not part of the contract (it happens + // to follow insertion in the current .NET runtime, but a future runtime + // is free to change that, and adding/removing entries can shift it today). var map = new Dictionary.Builder>(comparer); + var keys = new List(); foreach (var value in values) { var key = keySelector(value); @@ -527,14 +540,15 @@ public static IncrementalValuesProvider SelectAndReportExceptions(); map.Add(key, builder); + keys.Add(key); } builder.Add(elementSelector(value)); } - var result = ImmutableArray.CreateBuilder<(TKey, EquatableArray)>(map.Count); - foreach (var entry in map) - result.Add((entry.Key, entry.Value.ToImmutable().AsEquatableArray())); + var result = ImmutableArray.CreateBuilder<(TKey, EquatableArray)>(keys.Count); + foreach (var key in keys) + result.Add((key, map[key].ToImmutable().AsEquatableArray())); return result.MoveToImmutable(); }); } diff --git a/src/ANcpLua.Roslyn.Utilities/Models/GeneratorErrorInfo.cs b/src/ANcpLua.Roslyn.Utilities/Models/GeneratorErrorInfo.cs new file mode 100644 index 0000000..9506493 --- /dev/null +++ b/src/ANcpLua.Roslyn.Utilities/Models/GeneratorErrorInfo.cs @@ -0,0 +1,52 @@ +// Copyright (c) ANcpLua. All rights reserved. +// Licensed under the MIT License. + +namespace ANcpLua.Roslyn.Utilities.Models; + +/// +/// A value-equatable snapshot of an safe to flow through the +/// incremental generator cache. +/// +/// +/// +/// instances do not implement value equality, so storing one in +/// pipeline state forces every cache comparison to reference-equality and breaks +/// incremental caching across compilations. captures the +/// minimum information needed to surface the failure as a diagnostic — type name, message, +/// and stack trace — as immutable strings, which the record-struct equality contract +/// compares by value. +/// +/// +/// The exception's runtime type name (e.g. System.InvalidOperationException). +/// The exception's message, or empty if absent. +/// The exception's stack trace, or empty if absent. +#if ANCPLUA_ROSLYN_PUBLIC +public +#else +internal +#endif + readonly record struct GeneratorErrorInfo(string TypeName, string Message, string StackTrace) +{ + /// + /// Captures an as a value-equatable . + /// + public static GeneratorErrorInfo From(Exception exception) + { + if (exception is null) throw new ArgumentNullException(nameof(exception)); + return new GeneratorErrorInfo( + exception.GetType().FullName ?? exception.GetType().Name, + exception.Message ?? string.Empty, + exception.StackTrace ?? string.Empty); + } + + /// + /// Renders the captured error in Type: Message\n StackTrace form, mirroring + /// . + /// + public override string ToString() + { + return string.IsNullOrEmpty(StackTrace) + ? $"{TypeName}: {Message}" + : $"{TypeName}: {Message}{Environment.NewLine}{StackTrace}"; + } +} diff --git a/src/ANcpLua.Roslyn.Utilities/SourceProductionContextExtensions.cs b/src/ANcpLua.Roslyn.Utilities/SourceProductionContextExtensions.cs index 4f09f54..50c6446 100644 --- a/src/ANcpLua.Roslyn.Utilities/SourceProductionContextExtensions.cs +++ b/src/ANcpLua.Roslyn.Utilities/SourceProductionContextExtensions.cs @@ -183,6 +183,38 @@ public static void ReportException( context.ReportDiagnostic(exception.ToDiagnostic(id, prefix)); } + /// + /// Reports a captured as an error diagnostic. + /// + /// + /// Use this overload from incremental pipeline stages so the captured error can flow + /// through cache state by value rather than as a reference-equatable + /// . + /// + /// The source production context to report the diagnostic to. + /// The diagnostic ID (e.g., "GEN001"). + /// The captured error to report. + /// Optional prefix prepended to . + public static void ReportException( + this SourceProductionContext context, + string id, + GeneratorErrorInfo error, + string? prefix = null) + { + id = id ?? throw new ArgumentNullException(nameof(id)); + if (prefix is not null) id = $"{prefix}{id}"; + + context.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + id, + "Exception: ", + error.ToString(), + "Usage", + DiagnosticSeverity.Error, + true), + Location.None)); + } + /// /// Creates a from an exception. /// diff --git a/tests/ANcpLua.Roslyn.Utilities.Testing.Tests/IncrementalValuesProviderExtensionsTests.cs b/tests/ANcpLua.Roslyn.Utilities.Testing.Tests/IncrementalValuesProviderExtensionsTests.cs new file mode 100644 index 0000000..4dc080e --- /dev/null +++ b/tests/ANcpLua.Roslyn.Utilities.Testing.Tests/IncrementalValuesProviderExtensionsTests.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using ANcpLua.Roslyn.Utilities; +using ANcpLua.Roslyn.Utilities.Models; +using AwesomeAssertions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace ANcpLua.Roslyn.Utilities.Testing.Tests; + +/// +/// Regression tests for behaviors that affect +/// generator correctness: cache stability, cancellation propagation, deterministic emission order. +/// +public sealed class IncrementalValuesProviderExtensionsTests +{ + [Fact] + public void GeneratorErrorInfo_From_CapturesType_Message_AndStackTrace() + { + var caught = CaptureThrow(); + + var info = GeneratorErrorInfo.From(caught); + + info.TypeName.Should().Be(typeof(InvalidOperationException).FullName); + info.Message.Should().Be("boom"); + info.StackTrace.Should().NotBeNullOrEmpty(); + info.StackTrace.Should().Contain(nameof(ThrowDeep)); + info.ToString().Should().StartWith($"{typeof(InvalidOperationException).FullName}: boom"); + } + + [Fact] + public void GeneratorErrorInfo_HasValueEquality_SoItIsCacheStable() + { + // Two distinct Exception instances with identical surface produce equal GeneratorErrorInfos. + // This is the property that makes the type safe to flow through the incremental cache. + var first = CaptureThrow(); + var second = CaptureThrow(); + + var a = GeneratorErrorInfo.From(first) with { StackTrace = "fixed" }; + var b = GeneratorErrorInfo.From(second) with { StackTrace = "fixed" }; + + a.Should().Be(b); + a.GetHashCode().Should().Be(b.GetHashCode()); + ReferenceEquals(first, second).Should().BeFalse(); + } + + [Fact] + public void GroupBy_PreservesKeyInsertionOrder() + { + // The previous implementation iterated a Dictionary directly, which has no documented + // iteration order. Generators that consume GroupBy output must see a stable, deterministic + // sequence so generated source is reproducible across runs. + var observed = RunGroupByGenerator(GroupByOrderingGenerator.Source); + + observed.Should().Equal("Beta", "Alpha", "Gamma"); + } + + [Fact] + public void SelectAndReportExceptions_ValuesOverload_PropagatesCancellation() + { + // The pre-cancelled token is the *system under test* — using + // TestContext.Current.CancellationToken would defeat the purpose, so xUnit1051 is + // suppressed for this test only. + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var driver = CSharpGeneratorDriver.Create(new CancellingGenerator()); + var ct = TestContext.Current.CancellationToken; + var compilation = CSharpCompilation.Create( + "Test", + [CSharpSyntaxTree.ParseText("class C { }", cancellationToken: ct)], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]); + +#pragma warning disable xUnit1051 + Action act = () => driver.RunGenerators(compilation, cts.Token); +#pragma warning restore xUnit1051 + + act.Should().Throw(); + } + + private static InvalidOperationException CaptureThrow() + { + try + { + ThrowDeep(); + throw new InvalidOperationException("unreachable"); + } + catch (InvalidOperationException ex) + { + return ex; + } + } + + private static void ThrowDeep() + { + throw new InvalidOperationException("boom"); + } + + private static List RunGroupByGenerator(string source) + { + var ct = TestContext.Current.CancellationToken; + var compilation = CSharpCompilation.Create( + "Test", + [CSharpSyntaxTree.ParseText(source, cancellationToken: ct)], + references: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(new GroupByOrderingGenerator()); + driver = driver.RunGenerators(compilation, ct); + var result = driver.GetRunResult().Results.Single(); + result.Exception.Should().BeNull(); + + return GroupByOrderingGenerator.LastObservedKeys; + } + + /// + /// Probes the emission + /// order by feeding a fixed sequence and recording the keys back in a static field. + /// + private sealed class GroupByOrderingGenerator : IIncrementalGenerator + { + public const string Source = """ + namespace Probe + { + class Marker { } + } + """; + + public static List LastObservedKeys { get; } = []; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + LastObservedKeys.Clear(); + + var seed = context.CompilationProvider.SelectMany(static (_, _) => + ImmutableArray.Create( + new Item("Beta", 1), + new Item("Alpha", 1), + new Item("Beta", 2), + new Item("Gamma", 1), + new Item("Alpha", 2))); + + var grouped = seed.GroupBy(static x => x.Key, static x => x.Value); + + context.RegisterSourceOutput(grouped, static (_, group) => + { + lock (LastObservedKeys) + { + LastObservedKeys.Add(group.Key); + } + }); + } + + private readonly record struct Item(string Key, int Value); + } + + /// + /// Drives with a selector that observes the cancellation token to confirm cancellation flows out + /// of the pipeline rather than being swallowed. + /// + private sealed class CancellingGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var seed = context.CompilationProvider + .SelectMany(static (_, _) => ImmutableArray.Create(0)); + + seed.SelectAndReportExceptions( + static (_, ct) => + { + ct.ThrowIfCancellationRequested(); + return FileWithName.Empty; + }, + context) + .AddSource(context); + } + } +}