From 56ed221b5d6799aadaf6cd67fe31c29936e6dc4e Mon Sep 17 00:00:00 2001 From: Eric Sondergard <4650644+esond@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:46:31 -0600 Subject: [PATCH 1/3] Test two middlewares handling the same exception type --- ..._exception_same_type_in_two_middlewares.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs diff --git a/src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs b/src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs new file mode 100644 index 000000000..8e952eb18 --- /dev/null +++ b/src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs @@ -0,0 +1,128 @@ +using JasperFx; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Wolverine; +using Wolverine.Tracking; +using Xunit; + +namespace CoreTests.Acceptance; + +public class on_exception_same_type_in_two_middlewares +{ + [Fact] + public async Task both_handlers_run_in_registration_order() + { + var recorder = new SameTypeRecorder(); + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddSingleton(recorder); + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(SameTypeTriggerHandler)); + opts.Policies.AddMiddleware(typeof(FirstSameTypeMiddleware)); + opts.Policies.AddMiddleware(typeof(SecondSameTypeMiddleware)); + }).StartAsync(TestContext.Current.CancellationToken); + + await host.InvokeMessageAndWaitAsync(new SameTypeTrigger()); + + recorder.Calls.ShouldBe(["first", "second"]); + } + + [Fact] + public async Task both_handlers_can_return_a_value() + { + var recorder = new SameTypeRecorder(); + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddSingleton(recorder); + opts.Discovery.DisableConventionalDiscovery() + .IncludeType(typeof(SameTypeTriggerHandler)) + .IncludeType(typeof(SameTypeCascadeHandler)); + opts.Policies.AddMiddleware(typeof(FirstCascadingMiddleware)); + opts.Policies.AddMiddleware(typeof(SecondCascadingMiddleware)); + }).StartAsync(TestContext.Current.CancellationToken); + + await host.TrackActivity() + .DoNotAssertOnExceptionsDetected() + .InvokeMessageAndWaitAsync(new SameTypeTrigger()); + + recorder.Calls.ShouldBe(["first", "second"], ignoreOrder: true); + } + + [Fact] + public async Task both_handlers_can_return_a_continuation() + { + var recorder = new SameTypeRecorder(); + using var host = await Host.CreateDefaultBuilder() + .UseWolverine(opts => + { + opts.Services.AddSingleton(recorder); + opts.Discovery.DisableConventionalDiscovery().IncludeType(typeof(SameTypeTriggerHandler)); + opts.Policies.AddMiddleware(typeof(FirstContinuationMiddleware)); + opts.Policies.AddMiddleware(typeof(SecondContinuationMiddleware)); + }).StartAsync(TestContext.Current.CancellationToken); + + await host.InvokeMessageAndWaitAsync(new SameTypeTrigger()); + + recorder.Calls.ShouldBe(["first", "second"]); + } +} + +public static class FirstContinuationMiddleware +{ + public static HandlerContinuation OnException(ConcurrencyException ex, SameTypeRecorder recorder) + { + recorder.Calls.Add("first"); + return HandlerContinuation.Continue; + } +} + +public static class SecondContinuationMiddleware +{ + public static HandlerContinuation OnException(ConcurrencyException ex, SameTypeRecorder recorder) + { + recorder.Calls.Add("second"); + return HandlerContinuation.Continue; + } +} + +public record SameTypeCascade(string From); + +public static class SameTypeCascadeHandler +{ + public static void Handle(SameTypeCascade message, SameTypeRecorder recorder) => recorder.Calls.Add(message.From); +} + +public static class FirstCascadingMiddleware +{ + public static SameTypeCascade OnException(ConcurrencyException ex) => new("first"); +} + +public static class SecondCascadingMiddleware +{ + public static SameTypeCascade OnException(ConcurrencyException ex) => new("second"); +} + +public class SameTypeRecorder +{ + public List Calls { get; } = new(); +} + +public record SameTypeTrigger; + +public static class SameTypeTriggerHandler +{ + public static void Handle(SameTypeTrigger message) => + throw new ConcurrencyException("conflict", typeof(SameTypeTrigger), Guid.NewGuid()); +} + +public static class FirstSameTypeMiddleware +{ + public static void OnException(ConcurrencyException ex, SameTypeRecorder recorder) => recorder.Calls.Add("first"); +} + +public static class SecondSameTypeMiddleware +{ + public static void OnException(ConcurrencyException ex, SameTypeRecorder recorder) => recorder.Calls.Add("second"); +} From c878c19c196aad39aee3bd5dc6e2d50d57b55607 Mon Sep 17 00:00:00 2001 From: Eric Sondergard <4650644+esond@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:46:32 -0600 Subject: [PATCH 2/3] Merge OnException handlers for the same exception type into one catch clause Two middleware types that handled the same exception type generated two catch clauses for it, and the handler failed to compile with CS0160. A later handler for a type that is already caught now joins the existing clause, and the variables it creates get names no earlier handler in that clause uses. The handlers run in registration order until one of them ends the request or rethrows. --- .../Middleware/TryCatchFinallyFrame.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/Wolverine/Middleware/TryCatchFinallyFrame.cs b/src/Wolverine/Middleware/TryCatchFinallyFrame.cs index d35abd156..18f8c194f 100644 --- a/src/Wolverine/Middleware/TryCatchFinallyFrame.cs +++ b/src/Wolverine/Middleware/TryCatchFinallyFrame.cs @@ -32,6 +32,28 @@ public TryCatchFinallyFrame() : base(true) public void AddCatchBlock(Type exceptionType, Frame[] frames) { + // C# rejects a second catch clause for the same type (CS0160). + var existing = _catchBlocks.FirstOrDefault(x => x.ExceptionType == exceptionType); + if (existing != null) + { + // The merged handlers share one scope, so a variable can't reuse an earlier handler's name (CS0128). + var used = existing.Frames.SelectMany(x => x.Creates).Select(x => x.Usage).ToHashSet(); + foreach (var variable in frames.SelectMany(x => x.Creates).Where(x => !x.VariableType.IsValueTuple())) + { + var name = variable.Usage; + for (var i = 1; used.Contains(name); i++) + { + name = $"{variable.Usage}{i}"; + } + + variable.OverrideName(name); + used.Add(name); + } + + existing.Frames = [..existing.Frames, ..frames]; + return; + } + _catchBlocks.Add(new CatchBlock(exceptionType, frames)); SortCatchBlocks(); } @@ -212,7 +234,7 @@ public CatchBlock(Type exceptionType, Frame[] frames) } public Type ExceptionType { get; } - public Frame[] Frames { get; } + public Frame[] Frames { get; internal set; } public Variable ExceptionVariable { get; } internal static string DefaultExceptionVariableName(Type exceptionType) From ef7a71e223a84da135b6cfc93eb8bdeeea85513d Mon Sep 17 00:00:00 2001 From: Eric Sondergard <4650644+esond@users.noreply.github.com> Date: Thu, 5 Nov 2026 09:27:30 -0600 Subject: [PATCH 3/3] Record same-type OnException test calls in the thread-safe ActionLog Both cascaded messages in both_handlers_can_return_a_value can be handled in parallel off the local queue, and concurrent List.Add can drop one. --- .../Acceptance/on_exception_same_type_in_two_middlewares.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs b/src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs index 8e952eb18..4a4cd50c9 100644 --- a/src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs +++ b/src/Testing/CoreTests/Acceptance/on_exception_same_type_in_two_middlewares.cs @@ -106,7 +106,7 @@ public static class SecondCascadingMiddleware public class SameTypeRecorder { - public List Calls { get; } = new(); + public ActionLog Calls { get; } = new(); } public record SameTypeTrigger;