diff --git a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs index 06f3ec9df7..30f959ccea 100644 --- a/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs +++ b/src/modules/Elsa.Workflows.Core/Activities/Flowchart/Activities/Flowchart.Counters.cs @@ -313,11 +313,12 @@ private static async ValueTask MaybeScheduleWaitAnyActivityAsync(FlowGraph if (flowScope.AnyInboundConnectionsFollowed(flowGraph, outboundActivity)) { + // This is the first inbound connection followed; schedule the outbound activity + var scheduleResponse = await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); // An inbound connection has been followed; cancel remaining inbound activities await CancelRemainingInboundActivitiesAsync(flowchartContext, outboundActivity); - // This is the first inbound connection followed; schedule the outbound activity - return await ScheduleOutboundActivityAsync(flowchartContext, outboundActivity, completionCallback); + return scheduleResponse; } if (flowScope.AllInboundConnectionsVisited(flowGraph, outboundActivity)) diff --git a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs index fbcc4094c8..c589343af7 100644 --- a/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs +++ b/test/integration/Elsa.Activities.IntegrationTests/Flow/FlowchartCounterBasedTests.cs @@ -1,4 +1,5 @@ using Elsa.Testing.Shared; +using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Activities.Flowchart.Activities; using Elsa.Workflows.Activities.Flowchart.Models; @@ -341,4 +342,67 @@ public async Task HandlesUnconnectedActivities() Assert.Equal("Connected", _output.Lines.ElementAt(0)); Assert.DoesNotContain("Unconnected", _output.Lines); } + + /// + /// Regression test for the ordering fix in : + /// the outbound activity must be scheduled before remaining inbound branches are canceled. + /// Previously, canceling first could trigger CompleteIfNoPendingWorkAsync while the outbound + /// activity was not yet in the scheduler, causing the flowchart to finish prematurely without running + /// the activity downstream of the join. + /// + [Fact(DisplayName = "WaitAny join schedules outbound before canceling blocked branch, preventing premature completion")] + public async Task WaitAnyJoin_SchedulesOutboundBeforeCancelingBlockedBranch() + { + // Arrange + // Flowchart structure: + // Start + // ├─► Branch1 (fast) ─► Join (WaitAny) ─► AfterJoin + // └─► Branch2 (blocking, creates a bookmark) ─►┘ + // + // Because the scheduler is LIFO, Branch2 executes first and suspends with a bookmark. + // Branch1 then completes and triggers the WaitAny join. + // The join must schedule AfterJoin before canceling Branch2; otherwise + // CompleteIfNoPendingWorkAsync fires while AfterJoin is not yet queued, + // completing the flowchart prematurely without executing AfterJoin. + var start = new WriteLine("Start"); + var branch1 = new WriteLine("Branch1"); + var branch2 = new BlockingActivity { Id = "BlockingBranch" }; + var join = new FlowJoin { Mode = new(FlowJoinMode.WaitAny) }; + var afterJoin = new WriteLine("AfterJoin"); + + var flowchart = new Flowchart + { + Start = start, + Activities = { start, branch1, branch2, join, afterJoin }, + Connections = + { + CreateConnection(start, branch1), + CreateConnection(start, branch2), + CreateConnection(branch1, join), + CreateConnection(branch2, join), + CreateConnection(join, afterJoin) + } + }; + + // Act + var result = await RunFlowchartAsync(_services, flowchart, FlowchartExecutionMode.CounterBased); + + // Assert: the outbound path of the join must have executed + Assert.Contains("AfterJoin", _output.Lines); + + // Assert: the workflow must have finished, not suspended waiting for the canceled bookmark + Assert.Equal(WorkflowStatus.Finished, result.WorkflowState.Status); + + // Assert: the blocked branch's bookmark was cleared when it was canceled + Assert.Empty(result.WorkflowState.Bookmarks); + } + + /// + /// A minimal activity that suspends by creating a bookmark, simulating a blocking activity + /// such as a Delay or Timer that would normally be resumed by an external stimulus. + /// + private sealed class BlockingActivity : Activity + { + protected override void Execute(ActivityExecutionContext context) => context.CreateBookmark(); + } }