diff --git a/src/modules/Elsa.Workflows.Core/Signals/FaultSignal.cs b/src/modules/Elsa.Workflows.Core/Signals/FaultSignal.cs index f29072c0b..ff6305340 100644 --- a/src/modules/Elsa.Workflows.Core/Signals/FaultSignal.cs +++ b/src/modules/Elsa.Workflows.Core/Signals/FaultSignal.cs @@ -31,6 +31,15 @@ namespace Elsa.Workflows.Signals; /// when this signal does not exist at all. /// /// +/// The faulting activity is itself a receiver. SendSignalAsync delivers to the sender before walking its +/// ancestors, and this signal reuses that dispatch rather than introducing a variant of it. An activity that throws and +/// also handles therefore sees its own fault first, and may claim it, which is what a +/// self-retrying or self-compensating activity wants. This grants no ability to hide a failure that an activity did not +/// already have, since one that simply catches its own exception never faults at all. A handler that wants +/// ancestors-only semantics should check , or compare +/// against its own receiver context, the same way it already checks that the faulting context is one of its children. +/// +/// /// The contract. Responsibilities are split, and the split is deliberate: /// /// diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FaultSignals/Activities/SelfHandlingFaultingActivity.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FaultSignals/Activities/SelfHandlingFaultingActivity.cs new file mode 100644 index 000000000..0ae5cd096 --- /dev/null +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FaultSignals/Activities/SelfHandlingFaultingActivity.cs @@ -0,0 +1,17 @@ +using Elsa.Workflows.Signals; + +namespace Elsa.Workflows.IntegrationTests.Scenarios.FaultSignals.Activities; + +/// +/// An activity that throws and also claims its own , used to pin the channel's self-receipt +/// behavior: a signal is delivered to its sender before the walk up the ancestor chain begins. +/// +public class SelfHandlingFaultingActivity : CodeActivity +{ + public SelfHandlingFaultingActivity() + { + OnSignalReceived((_, context) => context.StopPropagation()); + } + + protected override void Execute(ActivityExecutionContext context) => throw new InvalidOperationException("Whoops!"); +} diff --git a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FaultSignals/FaultSignalTests.cs b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FaultSignals/FaultSignalTests.cs index 560cb9e8e..6552a7dff 100644 --- a/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FaultSignals/FaultSignalTests.cs +++ b/test/integration/Elsa.Workflows.IntegrationTests/Scenarios/FaultSignals/FaultSignalTests.cs @@ -77,6 +77,33 @@ public class FaultSignalTests(ITestOutputHelper testOutputHelper) Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus); } + [Fact(DisplayName = "The faulting activity receives its own fault before any ancestor does")] + public async Task FaultingActivity_ReceivesItsOwnSignalFirst() + { + // Pinning the channel's self-plus-ancestors dispatch, which this signal reuses rather than varying. It lets a + // self-retrying or self-compensating activity claim its own failure, and grants no ability to hide a failure + // that an activity did not already have: one that simply catches its own exception never faults at all. + + // Arrange + var faultingActivity = new SelfHandlingFaultingActivity(); + var container = ContainerAround(faultingActivity); + + // Act + var result = await RunAsync(container, typeof(FaultStrategy)); + + // Assert: the activity claimed its own fault, so the walk never reached the container. + Assert.Equal(0, container.FaultsSeen); + Assert.NotEqual(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus); + + // The incident is still on record, and the fault bookkeeping was still recovered exactly once. + Assert.Single(result.WorkflowState.Incidents); + + var faultedContext = result.GetActivityContext(faultingActivity); + Assert.NotNull(faultedContext); + Assert.Equal(0, faultedContext.AggregateFaultCount); + Assert.All(faultedContext.GetAncestors(), x => Assert.Equal(0, x.AggregateFaultCount)); + } + [Fact(DisplayName = "A handled fault restores the fault count on the faulting context and every ancestor")] public async Task HandledFault_RestoresFaultCounts() {