test(bpmn): event subprocesses, dormant and listener-backed (#7963)

* test(bpmn): event subprocesses, dormant and listener-backed (#7933)

Both flavours, and the three responsibilities the host has for them. The
production diff is empty: the applier and binder need no event-subprocess
code path, which is what #7909 measured and what this pins.

A dormant catcher -- error or escalation -- rides the FaultSignal seam and the
escalation signal path, and reaches the host as an ordinary StartWork for its
body. A listener-backed one gets a second StartWork for its listenerBindingRef
at scope start and a CancelWorkSubtree for it when the scope completes. Both
already apply like any other command.

The host responsibilities, each with the failure it would otherwise hide:

- The listener is armed at scope start and observable in the scope's own ledger
  before anything fires, not inferred from a fire that worked.
- A completing scope retires a still-armed listener. Pinned twice: at the root,
  where Elsa's own container completion would cancel the child regardless and
  only the scope's ledger tells the two apart, and inside a subprocess the
  workflow outlives, where a listener left behind is one something could still
  resume into.
- The start-element hint reaches the body and nothing else inherits it. The
  body's only start event is event-defined, so a body that never received the
  hint faults bpmn.start.none-available rather than starting somewhere
  plausible; the ordinary subprocess inside it faults bpmn.start.unresolved-hint
  if the hint travels where it must not. The scope's invocation correlation is
  read back after the body has run work of its own, because the dictionary is
  fixed for the scope's lifetime and the hint is read from it.
- A non-interrupting listener fired twice re-arms onto the slot the first fire
  vacated, holding one live record and one bookmark at a time. This is the case
  the completed-work-removed-before-the-interpreter-is-asked ordering exists
  for, and it is now observable; BpmnHostInvariantTests points at it.

The library's declaration rules are pinned as refusals rather than gaps: a body
with more than one start event, a second error-triggered event subprocess in a
scope, and a non-interrupting error event subprocess are each refused when the
scope builds its graph, before any work starts. The last is additionally
dropped at import, with the rest of the document reading as written -- the
dropped body carries an undeclared serviceTask, so an import that still
succeeds is what proves the drop took its bindings with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(bpmn): pin the catch-all escalation event subprocess refusal

Two error-triggered event subprocesses per scope was pinned but its sibling
rule -- at most one code-less catch-all escalation event subprocess -- had
no test. Add TwoCatchAllEscalationEventSubprocesses_AreRefused, mirroring
the error refusal test, and let Escalation() build a code-less definition.

Also record in BpmnCommandApplier why CancelSubtreeAsync's explicit
subtree cancellation is redundant on the scope-completion path (Elsa's own
container-completion behaviour already covers it) while the ledger removal
above it is not, so a future reader does not "simplify" the ledger removal
away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sipke Schoorstra 2026-08-20 01:02:51 +02:00 committed by GitHub
parent 37c98b2a78
commit fe83d2b385
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 698 additions and 3 deletions

View file

@ -147,6 +147,12 @@ internal sealed class BpmnCommandApplier(ActivityExecutionContext scopeContext,
if (BpmnWorkTeardown.FindContext(scopeContext.WorkflowExecutionContext, record.ChildContextId) is not { } childContext)
return;
// On the scope-completion path (an armed listener retired when its scope completes), this call is measured
// redundant with Elsa's own CompleteActivityAsync, which already cancels a completed container's
// non-completed children: see BpmnEventSubprocessTests.MessageEventSubprocess_RetiresTheStillArmedListenerWhenTheScopeCompletes,
// where every assertion but the ledger removal above still holds with this call skipped. The ledger removal
// is this host's own contribution and is not redundant. The fault-teardown path (BpmnScopeHost, tearing down
// a claimed fault's sibling work) is a different call site and was not part of that measurement.
await BpmnWorkTeardown.CancelSubtreeAsync(childContext, $"element '{cancel.ElementId}', {cancel.Reason}");
}

View file

@ -0,0 +1,285 @@
using Bpmn.Semantics;
using Elsa.Workflows;
using Elsa.Workflows.IncidentStrategies;
using Elsa.Workflows.Models;
using Xunit.Abstractions;
namespace Elsa.Bpmn.IntegrationTests.Scenarios.HostPort;
/// <summary>
/// Event subprocesses, both flavours, and the three things about them that are the host's to get right: a listener
/// armed at scope start and retired when the scope completes, the start-element hint reaching the body, and a
/// re-armed non-interrupting listener not colliding with the slot the fire that armed it just vacated.
/// </summary>
/// <remarks>
/// <para>
/// A dormant catcher — error or escalation — needs nothing armed: it rides the <c>FaultSignal</c> seam and the
/// escalation signal path, and what arrives at the host is an ordinary <c>StartWork</c> for the body. A
/// listener-backed catcher — message, signal or timer — additionally gets a <c>StartWork</c> for its
/// <c>listenerBindingRef</c> at scope start and a <c>CancelWorkSubtree</c> for it when the scope completes. Both are
/// commands the applier already handles, so these are process-level tests rather than tests of an
/// event-subprocess-specific code path: there is none.
/// </para>
/// <para>
/// Every process runs under <see cref="FaultStrategy"/>. An event subprocess's failure mode is a quiet one — a body
/// that was never seeded, a listener that was never armed, a catcher that never fired — and all three leave a
/// workflow that finished and reported nothing. Faulting rather than absorbing into an incident keeps a refusal this
/// host cannot honour from being buried under work that carried on regardless.
/// </para>
/// </remarks>
public class BpmnEventSubprocessTests(ITestOutputHelper testOutputHelper)
{
private readonly BpmnTestHost _host = new(testOutputHelper);
[Fact(DisplayName = "A dormant error-triggered event subprocess catches a fault in its scope and routes to its body")]
public async Task ErrorEventSubprocess_CatchesTheFaultAndRunsItsBody()
{
// The whole log, not a Contains: "the body ran" is also true of a process that carried on down the sequence
// flow afterwards, and 'after' is exactly the work an error the scope only appeared to claim would reach.
// 'cancelled:risky' is in it deliberately -- a scope claiming a fault terminalizes the whole unit of work
// that failed, and that teardown is the host's own doing rather than a command the interpreter issued.
// Act
var result = await _host.RunAsync(BpmnTestProcesses.ErrorEventSubprocess(_host.Log), typeof(FaultStrategy));
// Assert
Assert.Equal(["executed:risky", "cancelled:risky", "executed:handleError"], _host.Log.Entries);
// The disposition was Caught, so the scope claimed the fault and terminalized the failed work itself.
Assert.Equal(ActivityStatus.Canceled, StatusOf(result, "risky"));
// And a fault a container claimed is not an incident.
Assert.Empty(result.WorkflowState.Incidents);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A dormant escalation-triggered event subprocess catches an escalation raised in a nested scope")]
public async Task EscalationEventSubprocess_CatchesTheEscalationOutOfTheNestedScope()
{
// The scope-level catcher, not a boundary event on the subprocess: the escalation crosses the scope boundary
// on the same signal path, and what claims it is an event subprocess whose body start event declares the
// matching code. Non-interrupting, so the subprocess that escalated keeps running -- which is what tells
// "the catcher fired" apart from "the subprocess was stopped".
// Arrange
await _host.RunAsync(BpmnTestProcesses.EscalationEventSubprocessOutOfSubprocess(_host.Log), typeof(FaultStrategy));
// Act: the subprocess reaches its escalation throw event.
await _host.FinishWorkAsync("subWork");
// Assert: the event subprocess body ran...
Assert.Contains("executed:handleEscalation", _host.Log.Entries);
// ...and the escalating subprocess carried on past the throw rather than being torn down.
Assert.Contains("executed:subMore", _host.Log.Entries);
Assert.DoesNotContain("cancelled:subMore", _host.Log.Entries);
// And the subprocess still completes normally, so the main path continues.
var result = await _host.FinishWorkAsync("subMore");
Assert.Contains("executed:after", _host.Log.Entries);
Assert.Empty(result.WorkflowState.Incidents);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A message-triggered event subprocess arms its listener at scope start and runs its body when the trigger fires")]
public async Task MessageEventSubprocess_ArmsItsListenerAtScopeStartAndRunsItsBodyWhenFired()
{
// Armed *at scope start* is the claim, so it is asserted before the trigger fires and not inferred from the
// fire having worked. The scope's own ledger -- the sole source of BpmnHostSnapshot.LiveWork -- already holds
// the listener alongside the scope's ordinary work by the time that work runs.
// Arrange
await _host.RunAsync(BpmnTestProcesses.MessageEventSubprocess(_host.Log), typeof(FaultStrategy));
// Assert: armed, and nothing has fired.
Assert.Equal(
[BpmnTestProcesses.BindingRef("nudgeListener"), BpmnTestProcesses.BindingRef("work")],
_host.Log.Snapshot("liveWork@work"));
Assert.DoesNotContain("executed:handleNudge", _host.Log.Entries);
// Act: the trigger fires while the scope's own work is still running.
await _host.FinishWorkAsync("nudgeListener");
// Assert: the body ran, and the scope's own work is untouched -- non-interrupting means exactly that.
Assert.Equal(1, _host.Log.Occurrences("executed:handleNudge"));
Assert.DoesNotContain("cancelled:work", _host.Log.Entries);
var result = await _host.FinishWorkAsync("work");
Assert.Empty(result.WorkflowState.Incidents);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A scope completing with its listener still armed retires it, and the armed work does not survive")]
public async Task MessageEventSubprocess_RetiresTheStillArmedListenerWhenTheScopeCompletes()
{
// The quiet failure this pins: a listener left armed is a live child activity holding a bookmark on a scope
// that is already finished. The workflow reports Finished either way -- what differs is whether anything can
// still resume into a scope that has completed, which is a process that would run its event subprocess body
// after the process containing it ended.
// Arrange
await _host.RunAsync(BpmnTestProcesses.MessageEventSubprocess(_host.Log), typeof(FaultStrategy));
Assert.Contains(BpmnTestProcesses.BindingRef("nudgeListener"), _host.LiveWorkOf("scope").Select(work => work.BindingRef));
// Act: the scope's own work completes, which is the last thing keeping the scope open.
var result = await _host.FinishWorkAsync("work");
// Assert: the listener was torn down, never fired, and left nothing live behind it.
//
// The ledger assertion is the one that carries the weight here. At the root, Elsa tears a finished workflow's
// remaining children down on its own, so 'cancelled:nudgeListener' and the empty bookmark set would both hold
// even if the retirement command were dropped on the floor; what would not is the scope's own record of what
// it still has running. NestedMessageEventSubprocess_... below is the same retirement where the workflow
// outlives the scope, which is where the rest of it stops being free.
Assert.Contains("cancelled:nudgeListener", _host.Log.Entries);
Assert.DoesNotContain("executed:handleNudge", _host.Log.Entries);
Assert.Empty(_host.LiveWorkOf("scope"));
Assert.Empty(result.WorkflowState.Bookmarks);
Assert.Empty(result.WorkflowState.Incidents);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A nested scope completing with its listener still armed retires it while the workflow carries on")]
public async Task NestedMessageEventSubprocess_RetiresTheStillArmedListenerWhenTheNestedScopeCompletes()
{
// The same retirement, in the only shape where it is the scope's doing rather than the workflow's: the
// enclosing process keeps running afterwards, so a listener the scope failed to retire is one that outlives
// it and could still be resumed into a subprocess that has already finished.
// Arrange
await _host.RunAsync(BpmnTestProcesses.NestedMessageEventSubprocess(_host.Log), typeof(FaultStrategy));
Assert.Contains(BpmnTestProcesses.BindingRef("nudgeListener"), _host.LiveWorkOf("sub").Select(work => work.BindingRef));
// Act: the subprocess's own work completes, which is the last thing keeping the nested scope open.
var result = await _host.FinishWorkAsync("subWork");
// Assert: the nested scope retired its listener and completed, and the enclosing scope carried on.
Assert.Contains("cancelled:nudgeListener", _host.Log.Entries);
Assert.DoesNotContain("executed:handleNudge", _host.Log.Entries);
Assert.Empty(_host.LiveWorkOf("sub"));
Assert.Contains("executed:after", _host.Log.Entries);
// Nothing is left for a stimulus to resume into.
Assert.Empty(result.WorkflowState.Bookmarks);
Assert.Empty(result.WorkflowState.Incidents);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "A re-armed non-interrupting listener fires again cleanly, holding one live slot at a time")]
public async Task NonInterruptingListener_ReArmsWithoutCollidingWithTheSlotItJustVacated()
{
// The interpreter re-finds a parked token from (binding ref, iteration id) alone, and a re-armed listener
// keys onto the very slot the fire that armed it just vacated. It is only free because the completing
// listener was removed from the ledger before the interpreter was asked; leaving it there gives the scope two
// live records and two bookmarks for one slot, and the next teardown or completion resolves to whichever one
// it happens to find first.
//
// Firing twice rather than once is what makes that visible: the first fire is indistinguishable either way.
// Arrange
await _host.RunAsync(BpmnTestProcesses.MessageEventSubprocess(_host.Log), typeof(FaultStrategy));
// Act: fire once...
await _host.FinishWorkAsync("nudgeListener");
// Assert: the body ran, and exactly one listener is armed -- not the fresh one alongside the finished one.
Assert.Equal(1, _host.Log.Occurrences("executed:handleNudge"));
Assert.Equal(1, LiveListenerRecords());
// Act: ...and again, onto the slot the first fire vacated.
await _host.FinishWorkAsync("nudgeListener");
// Assert: a second, complete run of the body, and still exactly one armed listener.
Assert.Equal(2, _host.Log.Occurrences("executed:handleNudge"));
Assert.Equal(1, LiveListenerRecords());
var result = await _host.FinishWorkAsync("work");
// The scope retires that last listener and finishes, which a scope holding a stale second record could not do
// cleanly: the teardown would resolve the wrong record and leave the other bookmark behind.
Assert.Contains("cancelled:nudgeListener", _host.Log.Entries);
Assert.Empty(result.WorkflowState.Bookmarks);
Assert.Empty(result.WorkflowState.Incidents);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
}
[Fact(DisplayName = "An event subprocess body is seeded at the start event named on the hint, and nothing else inherits it")]
public async Task EventSubprocessBody_IsSeededAtTheHintedStartEvent()
{
// Both directions of the hint, in one process, and each fails loudly rather than quietly.
//
// The body's only start event is event-defined, so a body that did not receive the hint has nothing to begin
// at: it faults with bpmn.start.none-available rather than starting anywhere plausible. The ordinary
// subprocess inside the body is the other direction -- its own invocation carries an ordinary scheduling
// cause, so inheriting the hint would seed it at an element it does not declare and fault it with
// bpmn.start.unresolved-hint.
// Act
var result = await _host.RunAsync(BpmnTestProcesses.EventSubprocessBodyWithNestedSubprocess(_host.Log), typeof(FaultStrategy));
// Assert
Assert.Equal(["executed:risky", "cancelled:risky", "executed:handleError", "executed:innerOnly"], _host.Log.Entries);
Assert.Empty(result.WorkflowState.Incidents);
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
// The dictionary the hint arrives in belongs to the scope and is fixed for its lifetime. Read after the body
// has started and finished work of its own, it still says what the StartWork that created the scope said: a
// host that wrote a started or completing unit of work's correlation here would have overwritten it, and the
// damage would only surface the next time a body was seeded.
var correlation = _host.InvocationCorrelationOf("evtSub");
Assert.Equal("errStart", correlation[BpmnInterpreter.StartElementIdCorrelationKey]);
Assert.Equal(BpmnInterpreter.EventSubprocessBodySchedulingCause, correlation[BpmnInterpreter.SchedulingCauseCorrelationKey]);
}
[Fact(DisplayName = "An event subprocess body declaring more than one start event is refused")]
public Task EventSubprocessBodyWithTwoStartEvents_IsRefused() =>
AssertRefusedAsync(BpmnTestProcesses.EventSubprocessBodyWithTwoStartEvents(_host.Log), "body must declare exactly one start event");
[Fact(DisplayName = "A second error-triggered event subprocess in one scope is refused")]
public Task TwoErrorEventSubprocesses_AreRefused() =>
AssertRefusedAsync(BpmnTestProcesses.TwoErrorEventSubprocesses(_host.Log), "more than one error event subprocess");
[Fact(DisplayName = "A second code-less catch-all escalation-triggered event subprocess in one scope is refused")]
public Task TwoCatchAllEscalationEventSubprocesses_AreRefused() =>
AssertRefusedAsync(
BpmnTestProcesses.TwoCatchAllEscalationEventSubprocesses(_host.Log),
"more than one code-less catch-all escalation event subprocess");
[Fact(DisplayName = "A non-interrupting error-triggered event subprocess is refused")]
public Task NonInterruptingErrorEventSubprocess_IsRefused() =>
AssertRefusedAsync(BpmnTestProcesses.NonInterruptingErrorEventSubprocess(_host.Log), "must be interrupting");
/// <summary>
/// Asserts a process the library refuses is refused, and refused before anything ran.
/// </summary>
/// <remarks>
/// These are the library's rules about how an event subprocess may be declared, and it enforces them when the
/// scope builds its graph — before a single unit of work is started. Pinned here so a future reader meets them as
/// refusals rather than as gaps: the process does not half-run and then stop, it never starts.
/// </remarks>
private async Task AssertRefusedAsync(IActivity process, string expectedMessageFragment)
{
var result = await _host.RunAsync(process, typeof(FaultStrategy));
Assert.Empty(_host.Log.Entries);
Assert.Equal(WorkflowSubStatus.Faulted, result.WorkflowState.SubStatus);
var incident = Assert.Single(result.WorkflowState.Incidents);
Assert.Contains(expectedMessageFragment, incident.Exception!.Message);
}
private int LiveListenerRecords() =>
_host.LiveWorkOf("scope").Count(record => record.BindingRef == BpmnTestProcesses.BindingRef("nudgeListener"));
private static ActivityStatus? StatusOf(RunWorkflowResult result, string activityId) =>
result.Journal.ActivityExecutionContexts.FirstOrDefault(x => x.Activity.Id == activityId)?.Status;
}

View file

@ -42,7 +42,9 @@ public class BpmnHostInvariantTests(ITestOutputHelper testOutputHelper)
// ordering is not asserted here because it is not observable for these constructs: the interpreter reads
// LiveWork only to resolve teardown handles by (binding ref, iteration id), and none of the processes in scope
// tears down a slot a just-completed unit of work shares. It becomes observable with multi-instance work and
// re-armed scope listeners, which arrive with the issues that add them.
// re-armed scope listeners, which arrive with the issues that add them -- see
// BpmnEventSubprocessTests.NonInterruptingListener_ReArmsWithoutCollidingWithTheSlotItJustVacated, which is
// where the ordering is pinned.
// Arrange
await _host.RunAsync(BpmnTestProcesses.InterruptingTimerBoundary(_host.Log));

View file

@ -85,6 +85,22 @@ public sealed class BpmnTestHost
return ledger.Records.Select(record => (record.BindingRef, record.IterationId)).ToList();
}
/// <summary>
/// The invocation correlation the named BPMN scope carries — the dictionary the scope that started it wrote onto
/// its context, and the one an event subprocess body's start-element hint is read from.
/// </summary>
/// <remarks>
/// It belongs to the scope and is fixed for its lifetime, so reading it after the scope has started and finished
/// work is what makes "nothing overwrote it" observable rather than merely documented.
/// </remarks>
public IReadOnlyDictionary<string, string> InvocationCorrelationOf(string scopeActivityId)
{
var scopeContext = _result!.Journal.ActivityExecutionContexts.First(x => x.Activity.Id == scopeActivityId);
return BpmnScopeMemory.Read<Dictionary<string, string>>(scopeContext, BpmnScopeHost.InvocationCorrelationPropertyKey)
?? new Dictionary<string, string>(StringComparer.Ordinal);
}
/// <summary>
/// Replaces the current <see cref="WorkflowState"/> with what a round trip through Elsa's own
/// <see cref="IWorkflowStateSerializer"/> hands back — what a real persistence store would return on load,

View file

@ -472,6 +472,294 @@ internal static class BpmnTestProcesses
return Scope("scope", definition, nested, Immediate("undoSub", log));
}
/// <summary>
/// A task that fails, with a dormant error-triggered event subprocess in the same scope to catch it.
/// </summary>
/// <remarks>
/// An error event subprocess arms nothing: it rides the same <c>FaultSignal</c> seam an error boundary event does,
/// and the only thing that distinguishes it here is where the recovery work runs — inside a nested scope of its
/// own, seeded at the body's error start event, rather than on an outbound flow of the enclosing graph.
/// </remarks>
public static BpmnProcess ErrorEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("error-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Build();
var definition = new BpmnProcessBuilder("error-event-subprocess")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "risky", "after", "end")
.Build();
return Scope("scope", definition, Faulting("risky", log), Immediate("after", log), Scope("evtSub", body, Immediate("handleError", log)));
}
/// <summary>
/// An escalation thrown out of an embedded subprocess, caught by a non-interrupting escalation-triggered event
/// subprocess on the enclosing scope rather than by a boundary event on the subprocess.
/// </summary>
/// <remarks>
/// Non-interrupting, so the escalating subprocess keeps running and nothing in the scope is torn down. That is
/// also what makes "the scope-level catcher fired" distinguishable from "the subprocess was stopped": with an
/// interrupting catcher the two are the same observation.
/// </remarks>
public static BpmnProcess EscalationEventSubprocessOutOfSubprocess(BpmnTestLog log)
{
var subBody = new BpmnProcessBuilder("escalating-subprocess-body")
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.IntermediateThrowEvent("subEscalate", Escalation("REVIEW"))
.Task("subMore", bindingRef: BindingRef("subMore"))
.EndEvent("subEnd")
.ConnectSequence("subStart", "subWork", "subEscalate", "subMore", "subEnd")
.Build();
var handlerBody = new BpmnProcessBuilder("escalation-event-subprocess-body")
.Element(EventSubprocessStart("escStart", Escalation("REVIEW"), interrupting: false))
.Task("handleEscalation", bindingRef: BindingRef("handleEscalation"))
.EndEvent("escEnd")
.ConnectSequence("escStart", "handleEscalation", "escEnd")
.Build();
var definition = new BpmnProcessBuilder("escalation-event-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope("sub", subBody, Blocking("subWork", log), Blocking("subMore", log));
return Scope("scope", definition, nested, Immediate("after", log), Scope("evtSub", handlerBody, Immediate("handleEscalation", log)));
}
/// <summary>
/// A non-interrupting message-triggered event subprocess: a listener armed at scope start, and a body that runs
/// each time the listener fires while the scope's own long-running work is still going.
/// </summary>
/// <remarks>
/// <para>
/// The listener is the second binding channel — <c>listenerBindingRef</c> — and is bound in the same
/// <c>WorkBindings</c> map as everything else. It stands in for a real message wait: blocking work a test
/// finishes, which is exactly what "the trigger fired" means to the host.
/// </para>
/// <para>
/// <c>work</c> blocks so the scope stays open across the fires and so that when it finally completes, the armed
/// listener is a <em>running</em> activity rather than a scheduled-but-not-yet-invoked one — the second of which
/// this host cannot withdraw at all.
/// </para>
/// </remarks>
public static BpmnProcess MessageEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("message-event-subprocess-body")
.Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
.Task("handleNudge", bindingRef: BindingRef("handleNudge"))
.EndEvent("msgEnd")
.ConnectSequence("msgStart", "handleNudge", "msgEnd")
.Build();
var definition = new BpmnProcessBuilder("message-event-subprocess")
.StartEvent("start")
.Task("work", bindingRef: BindingRef("work"))
.EndEvent("end")
.Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
.ConnectSequence("start", "work", "end")
.Build();
return Scope(
"scope",
definition,
Blocking("work", log),
Blocking("nudgeListener", log),
Scope("evtSub", body, Immediate("handleNudge", log)));
}
/// <summary>
/// The same message-triggered event subprocess, but inside an embedded subprocess that completes while the
/// enclosing scope carries on — so a listener that outlived the scope that armed it is distinguishable from one
/// that merely outlived the workflow.
/// </summary>
/// <remarks>
/// At the root, "the armed work does not survive the scope" and "does not survive the workflow" are the same
/// observation, and Elsa tears a finished workflow's children down regardless. Here the workflow keeps running
/// after the scope that armed the listener has completed, which is the only shape in which a listener left behind
/// is a listener something could still resume into.
/// </remarks>
public static BpmnProcess NestedMessageEventSubprocess(BpmnTestLog log)
{
var handlerBody = new BpmnProcessBuilder("nested-message-event-subprocess-body")
.Element(EventSubprocessStart("msgStart", Message("nudge"), interrupting: false))
.Task("handleNudge", bindingRef: BindingRef("handleNudge"))
.EndEvent("msgEnd")
.ConnectSequence("msgStart", "handleNudge", "msgEnd")
.Build();
var subBody = new BpmnProcessBuilder("listening-subprocess-body")
.StartEvent("subStart")
.Task("subWork", bindingRef: BindingRef("subWork"))
.EndEvent("subEnd")
.Element(EventSubprocess("evtSub", listenerBindingRef: BindingRef("nudgeListener")))
.ConnectSequence("subStart", "subWork", "subEnd")
.Build();
var definition = new BpmnProcessBuilder("nested-message-event-subprocess")
.StartEvent("start")
.SubProcess("sub", bindingRef: BindingRef("sub"))
.Task("after", bindingRef: BindingRef("after"))
.EndEvent("end")
.ConnectSequence("start", "sub", "after", "end")
.Build();
var nested = Scope(
"sub",
subBody,
Blocking("subWork", log),
Blocking("nudgeListener", log),
Scope("evtSub", handlerBody, Immediate("handleNudge", log)));
return Scope("scope", definition, nested, Immediate("after", log));
}
/// <summary>
/// An error-triggered event subprocess whose body runs an ordinary embedded subprocess of its own, so the
/// start-element hint has both a place to arrive and a place it must not reach.
/// </summary>
/// <remarks>
/// <para>
/// The body's only start event is event-defined, which is what makes the hint's arrival observable rather than
/// merely asserted: seeded from the hint the body runs, and seeded as an ordinary direct invocation it faults
/// deterministically with <c>bpmn.start.none-available</c>, because there is no none start event to begin at.
/// </para>
/// <para>
/// The nested <c>inner</c> subprocess is the other direction. Its own invocation carries an ordinary scheduling
/// cause, so the hint must not be inherited: were it, the inner process would be seeded at an element it does not
/// declare and fault with <c>bpmn.start.unresolved-hint</c> instead of starting at its own none start event.
/// </para>
/// </remarks>
public static BpmnProcess EventSubprocessBodyWithNestedSubprocess(BpmnTestLog log)
{
var innerBody = new BpmnProcessBuilder("event-subprocess-inner-body")
.StartEvent("innerStart")
.Task("innerOnly", bindingRef: BindingRef("innerOnly"))
.EndEvent("innerEnd")
.ConnectSequence("innerStart", "innerOnly", "innerEnd")
.Build();
var body = new BpmnProcessBuilder("hinted-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.Task("handleError", bindingRef: BindingRef("handleError"))
.SubProcess("inner", bindingRef: BindingRef("inner"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "inner", "errEnd")
.Build();
var definition = new BpmnProcessBuilder("event-subprocess-start-hint")
.StartEvent("start")
.Task("risky", bindingRef: BindingRef("risky"))
.EndEvent("end")
.Element(EventSubprocess("evtSub"))
.ConnectSequence("start", "risky", "end")
.Build();
var handler = Scope("evtSub", body, Immediate("handleError", log), Scope("inner", innerBody, Immediate("innerOnly", log)));
return Scope("scope", definition, Faulting("risky", log), handler);
}
/// <summary>An event subprocess whose body declares two start events, which the library refuses.</summary>
public static BpmnProcess EventSubprocessBodyWithTwoStartEvents(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("two-start-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error()))
.StartEvent("alsoStart")
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Connect("alsoStart", "handleError")
.Build();
return RefusedEventSubprocessScope("two-start-events", log, ("evtSub", body, "handleError"));
}
/// <summary>Two error-triggered event subprocesses in one scope, which the library refuses.</summary>
public static BpmnProcess TwoErrorEventSubprocesses(BpmnTestLog log)
{
BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-error-event-subprocess-body")
.Element(EventSubprocessStart($"{prefix}Start", Error()))
.Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
.EndEvent($"{prefix}End")
.ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
.Build();
return RefusedEventSubprocessScope(
"two-error-event-subprocesses",
log,
("evtSubA", Body("first"), "firstHandle"),
("evtSubB", Body("second"), "secondHandle"));
}
/// <summary>Two code-less catch-all escalation-triggered event subprocesses in one scope, which the library refuses.</summary>
public static BpmnProcess TwoCatchAllEscalationEventSubprocesses(BpmnTestLog log)
{
BpmnProcessDefinition Body(string prefix) => new BpmnProcessBuilder($"{prefix}-escalation-event-subprocess-body")
.Element(EventSubprocessStart($"{prefix}Start", Escalation(), interrupting: false))
.Task($"{prefix}Handle", bindingRef: BindingRef($"{prefix}Handle"))
.EndEvent($"{prefix}End")
.ConnectSequence($"{prefix}Start", $"{prefix}Handle", $"{prefix}End")
.Build();
return RefusedEventSubprocessScope(
"two-catch-all-escalation-event-subprocesses",
log,
("evtSubA", Body("first"), "firstHandle"),
("evtSubB", Body("second"), "secondHandle"));
}
/// <summary>A non-interrupting error-triggered event subprocess, which is not legal BPMN and which the library refuses.</summary>
public static BpmnProcess NonInterruptingErrorEventSubprocess(BpmnTestLog log)
{
var body = new BpmnProcessBuilder("non-interrupting-error-event-subprocess-body")
.Element(EventSubprocessStart("errStart", Error(), interrupting: false))
.Task("handleError", bindingRef: BindingRef("handleError"))
.EndEvent("errEnd")
.ConnectSequence("errStart", "handleError", "errEnd")
.Build();
return RefusedEventSubprocessScope("non-interrupting-error-event-subprocess", log, ("evtSub", body, "handleError"));
}
/// <summary>
/// The <c>start/only/end</c> graph the refusal processes share, carrying the event subprocesses whose declaration
/// the library refuses. Nothing in it ever runs: the refusal is raised when the scope builds its graph, which is
/// before any work is started.
/// </summary>
private static BpmnProcess RefusedEventSubprocessScope(string processId, BpmnTestLog log, params (string ElementId, BpmnProcessDefinition Body, string HandlerId)[] eventSubprocesses)
{
var builder = new BpmnProcessBuilder(processId)
.StartEvent("start")
.Task("only", bindingRef: BindingRef("only"))
.EndEvent("end")
.ConnectSequence("start", "only", "end");
foreach (var eventSubprocess in eventSubprocesses)
builder = builder.Element(EventSubprocess(eventSubprocess.ElementId));
var work = new List<IActivity> { Immediate("only", log) };
work.AddRange(eventSubprocesses.Select(eventSubprocess => Scope(eventSubprocess.ElementId, eventSubprocess.Body, Immediate(eventSubprocess.HandlerId, log))));
return Scope("scope", builder.Build(), work.ToArray());
}
/// <summary>An embedded subprocess with one task in it, and one task after it in the enclosing scope.</summary>
public static BpmnProcess NestedSubprocess(BpmnTestLog log)
{
@ -576,8 +864,34 @@ internal static class BpmnTestProcesses
private static BpmnElement CompensationHandler(string elementId) =>
new(elementId, BpmnElementTypes.Task, bindingRef: BindingRef(elementId), isForCompensation: true);
private static BpmnEventDefinition Escalation(string code) =>
new(BpmnEventDefinitionTypes.Escalation, new Dictionary<string, string>(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.Code] = code });
private static BpmnEventDefinition Escalation(string? code = null) =>
code is null
? new(BpmnEventDefinitionTypes.Escalation)
: new(BpmnEventDefinitionTypes.Escalation, new Dictionary<string, string>(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.Code] = code });
private static BpmnEventDefinition Error() => new(BpmnEventDefinitionTypes.Error);
private static BpmnEventDefinition Message(string name) =>
new(BpmnEventDefinitionTypes.Message, new Dictionary<string, string>(StringComparer.Ordinal) { [BpmnEventDefinitionProperties.Name] = name });
/// <summary>
/// An event subprocess: a flow-less subprocess whose bound work is the body, activated by that body's single
/// start event rather than by a sequence flow. <see cref="BpmnProcessBuilder.SubProcess"/> carries the
/// <c>triggeredByEvent</c> flag but not the listener binding, so this one is written out.
/// </summary>
private static BpmnElement EventSubprocess(string elementId, string? listenerBindingRef = null) =>
new(elementId,
BpmnElementTypes.SubProcess,
bindingRef: BindingRef(elementId),
triggeredByEvent: true,
listenerBindingRef: listenerBindingRef);
/// <summary>
/// An event subprocess body's single start event, carrying its trigger and its <c>isInterrupting</c> flag.
/// <see cref="BpmnProcessBuilder.StartEvent"/> cannot carry the flag, so this one is written out.
/// </summary>
private static BpmnElement EventSubprocessStart(string elementId, BpmnEventDefinition trigger, bool interrupting = true) =>
new(elementId, BpmnElementTypes.StartEvent, eventDefinitions: [trigger], cancelActivity: interrupting);
private static BpmnProcess Scope(string id, BpmnProcessDefinition definition, params IActivity[] work) => Scope(id, definition, [], work);

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
id="Definitions_non-interrupting-error-event-subprocess"
targetNamespace="http://bpmn.io/schema/bpmn">
<!--
A non-interrupting error event subprocess, which is not legal BPMN: error events are always interrupting. The
reader drops the whole <subProcess>, bindings included, and reports it. The undeclared <serviceTask> in its body
is deliberate: an unbound task the binder can see is refused outright, so an import that still succeeds is what
proves the drop took the body's bindings with it rather than leaving half the element behind.
-->
<bpmn:process id="non-interrupting-error-event-subprocess" name="Non-Interrupting Error Event Subprocess" isExecutable="true">
<bpmn:startEvent id="Start_1">
<bpmn:outgoing>Flow_1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:intermediateCatchEvent id="Settle">
<bpmn:incoming>Flow_1</bpmn:incoming>
<bpmn:outgoing>Flow_2</bpmn:outgoing>
<bpmn:timerEventDefinition>
<bpmn:timeDuration>PT5M</bpmn:timeDuration>
</bpmn:timerEventDefinition>
</bpmn:intermediateCatchEvent>
<bpmn:endEvent id="End_1">
<bpmn:incoming>Flow_2</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1" sourceRef="Start_1" targetRef="Settle" />
<bpmn:sequenceFlow id="Flow_2" sourceRef="Settle" targetRef="End_1" />
<bpmn:subProcess id="OnError" name="On Error" triggeredByEvent="true">
<bpmn:startEvent id="ErrorStart" isInterrupting="false">
<bpmn:errorEventDefinition id="ErrorDefinition_1" />
<bpmn:outgoing>Flow_E1</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:serviceTask id="HandleError" name="Handle Error">
<bpmn:incoming>Flow_E1</bpmn:incoming>
<bpmn:outgoing>Flow_E2</bpmn:outgoing>
</bpmn:serviceTask>
<bpmn:endEvent id="ErrorEnd">
<bpmn:incoming>Flow_E2</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_E1" sourceRef="ErrorStart" targetRef="HandleError" />
<bpmn:sequenceFlow id="Flow_E2" sourceRef="HandleError" targetRef="ErrorEnd" />
</bpmn:subProcess>
</bpmn:process>
</bpmn:definitions>

View file

@ -45,6 +45,35 @@ public class BpmnInterchangeDocumentServiceTests(ITestOutputHelper testOutputHel
Assert.Contains(result.Analysis.Issues, issue => issue.ElementId == "NotifyWarehouse" && issue.Severity == BpmnImportIssueSeverity.Info);
}
[Fact(DisplayName = "A non-interrupting error event subprocess is dropped at import, and the rest of the document still imports")]
public async Task Import_DropsANonInterruptingErrorEventSubprocess()
{
// A refusal, not a bug, and not a failed import: error events are always interrupting per BPMN, so the reader
// reports the whole <subProcess> as Dropped and reads the rest of the document as written.
//
// The import succeeding is what proves the drop was total. The dropped body declares an undeclared
// <serviceTask>, and an unbound task the binder can see is refused outright -- so a drop that reported the
// element but left its bindings behind would surface here as a BpmnBindingException naming 'HandleError',
// not as a quietly half-imported process.
var xml = ReadAsset("non-interrupting-error-event-subprocess.bpmn");
var analysis = DocumentService.Analyze(xml);
var dropped = Assert.Single(analysis.Issues, candidate => candidate.Severity == BpmnImportIssueSeverity.Dropped);
Assert.Equal("OnError", dropped.ElementId);
Assert.Contains("non-interrupting error event subprocess", dropped.Message);
// The reader's own findings about the dropped body's elements survive the drop, at Info: the body is read
// before the rule that drops the element around it is applied. Pinned so a future reader meets it as the
// library's behaviour rather than as evidence the drop was partial -- what proves it was total is the import.
Assert.Contains(analysis.Issues, candidate => candidate.ElementId == "HandleError" && candidate.Severity == BpmnImportIssueSeverity.Info);
var imported = await DocumentService.ImportAsync(xml, definitionId: null, name: null, processId: null, CancellationToken.None);
Assert.True(imported.ImportResult.Succeeded, string.Join("; ", imported.ImportResult.ValidationErrors.Select(error => error.Message)));
}
[Fact(DisplayName = "Exporting an imported definition retains foreign extension elements, foreign attributes and waypoints byte-identically")]
public async Task Export_RetainsForeignContentAndWaypoints()
{