Enforce StateMachine trigger identity boundaries

This commit is contained in:
Sipke Schoorstra 2026-08-31 02:54:01 +02:00
parent 051e12f864
commit 6a2e530d77
No known key found for this signature in database
GPG key ID: 5C10502B28A4268F
2 changed files with 62 additions and 5 deletions

View file

@ -51,15 +51,13 @@ public class StateMachine : Activity
/// </summary>
[JsonIgnore]
[Browsable(false)]
public IEnumerable<IActivity> Activities =>
States.SelectMany(x => new[] { x.Entry, x.Exit })
.Concat(Transitions.SelectMany(x => new[] { x.Trigger, x.Action }))
.Where(x => x != null)
.Cast<IActivity>();
public IEnumerable<IActivity> Activities => GetActivities();
/// <inheritdoc />
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
EnsureSupportedTriggerIdentities();
var currentState = GetCurrentState(context);
SetCurrentState(context, string.IsNullOrWhiteSpace(currentState) ? InitialState : currentState);
@ -281,6 +279,41 @@ public class StateMachine : Activity
private string? GetCurrentState(ActivityExecutionContext context) => context.GetProperty<string>(CurrentStateProperty) ?? CurrentState;
private IEnumerable<IActivity> GetActivities()
{
foreach (var stateActivity in States.SelectMany(x => new[] { x.Entry, x.Exit }).Where(x => x != null))
yield return stateActivity!;
var seenTriggerInstances = new HashSet<IActivity>(ReferenceEqualityComparer.Instance);
var seenTriggerIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var transition in Transitions)
{
if (transition.Trigger != null && seenTriggerInstances.Add(transition.Trigger) &&
(string.IsNullOrWhiteSpace(transition.Trigger.Id) || seenTriggerIds.Add(transition.Trigger.Id)))
yield return transition.Trigger;
if (transition.Action != null)
yield return transition.Action;
}
}
private void EnsureSupportedTriggerIdentities()
{
var triggers = Transitions.Where(x => x.Trigger != null).Select(x => x.Trigger!).ToList();
var seenInstances = new HashSet<IActivity>(ReferenceEqualityComparer.Instance);
var seenIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var trigger in triggers)
{
var sharesInstance = !seenInstances.Add(trigger);
var duplicatesId = !string.IsNullOrWhiteSpace(trigger.Id) && !seenIds.Add(trigger.Id);
if (sharesInstance || duplicatesId)
throw new InvalidOperationException("StateMachine transitions cannot share a Trigger activity in Elsa 3.8. Give each transition its own trigger activity with a unique ID.");
}
}
private void SetCurrentState(ActivityExecutionContext context, string? state)
{
CurrentState = state;

View file

@ -201,6 +201,7 @@ public class StateMachineTests
var context = await ExecuteAndEnterNewStateAsync();
_stateMachine.Transitions.Single(x => x.Name == "Pay").Condition = new(false);
var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger);
var cancelBookmark = cancelTriggerContext.CreateBookmark("cancel");
var scheduledPayTriggerCount = CountScheduledActivities(context, _payTrigger);
await CompleteScheduledActivityAsync(context, _payTrigger);
@ -209,6 +210,7 @@ public class StateMachineTests
Assert.False(context.HasScheduledActivity(_payAction));
Assert.Equal(scheduledPayTriggerCount + 1, CountScheduledActivities(context, _payTrigger));
Assert.NotEqual(ActivityStatus.Canceled, cancelTriggerContext.Status);
Assert.Contains(cancelBookmark, context.WorkflowExecutionContext.Bookmarks);
}
[Fact(DisplayName = "StateMachine cancels competing outbound triggers when a transition wins")]
@ -216,10 +218,32 @@ public class StateMachineTests
{
var context = await ExecuteAndEnterNewStateAsync();
var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger);
var cancelBookmark = cancelTriggerContext.CreateBookmark("cancel");
await CompleteScheduledActivityAsync(context, _payTrigger);
Assert.Equal(ActivityStatus.Canceled, cancelTriggerContext.Status);
Assert.DoesNotContain(cancelBookmark, context.WorkflowExecutionContext.Bookmarks);
}
[Fact(DisplayName = "StateMachine rejects transitions that share a trigger instance")]
public async Task SharedTriggerInstanceIsRejected()
{
_stateMachine.Transitions.Single(x => x.Name == "Cancel").Trigger = _payTrigger;
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteAsync());
Assert.Contains("cannot share a Trigger activity", exception.Message);
}
[Fact(DisplayName = "StateMachine rejects transition triggers with duplicate activity IDs")]
public async Task DuplicateTriggerIdIsRejected()
{
_cancelTrigger.Id = _payTrigger.Id;
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => ExecuteAsync());
Assert.Contains("unique ID", exception.Message);
}
[Fact(DisplayName = "StateMachine removes re-armed competing triggers when a different transition wins")]