Skip to content

fix(microflows): an error handler inside a branch continues after its failing activity - #1636

Closed
stagknee wants to merge 1 commit into
mendixlabs:mainfrom
stagknee:fix/1565-branch-error-handler
Closed

stagknee wants to merge 1 commit into
mendixlabs:mainfrom
stagknee:fix/1565-branch-error-handler

Conversation

@stagknee

Copy link
Copy Markdown
Contributor

Closes #1565.

What it fixes

A custom error handler that doesn't end in return or raise error continues with the statement after the failing activity. On an activity inside an IF, CASE or split type branch, it was wired outside the branch:

Shape Before After
Call with a result ($N = call microflow …, $Response = call rest service … returns response) Rejoins on the flow into the split. A failing call re-runs the decision and calls again, for as long as it keeps failing. Rejoins at the statement after the call
Call without a result Ends the microflow at a new end event. In a flow with a named return value, that end event returns an undefined variable (CE0109). Rejoins at the statement after the call
Call without a result, in a branch that ends in return Rejoins after the IF, skipping the rest of the branch Rejoins at the statement after the call
Handler on the branch's last activity Loop or new end event, as above Rejoins where the branch leaves (the IF's merge)

mx check accepts all of these except the CE0109 case.

Cause

The builder keeps a handler pending until the next flow is drawn from the failing activity. buildFlowGraph settles it on every flow it draws. The branch loops settled only an empty handler (if fb.emptyErrorHandlerFrom == lastID); the IF-without-ELSE and split type loops didn't settle any. So a handler with a body was still pending when the branch ended.

The parent draws the flow into the split only after it has built the split. A handler with a result variable settles on the next flow, whatever that flow's origin, and that flow was the one into the split. A handler without one matched nothing, so the end-of-flow pass gave it an end event.

How

  • Each branch settles its own handlers. All three IF branch loops, CASE and split type settle on every flow they draw, as buildFlowGraph does. They also settle on the branch's exit flow to the merge, which covers a handler on the branch's last activity.
  • The handlers pending when a split starts belong to the activity before it. A result-variable handler settles on any flow, so if it were left visible to the branches it would rejoin in the middle of the first one. Each branch therefore sets those handlers aside and restores them afterwards (enterBranchErrorHandlers / takeBranchErrorHandlers).
  • Your returning-branch rule is kept. In a branch that ends in return, a handler on an activity with a result still goes to the code after the split, the shared error path of a success tail built on the result (TestBuildFlowGraph_OutputHandlerInReturningBranchSkipsDerivedSuccessTail, unchanged and passing).
    • Such a handler, and any other a branch can't settle, is now pinned to the split's exit: the merge, the continuing branch's last activity, or the split itself when every branch returns (queueBranchErrorHandlersAt).
    • It therefore rejoins on the flow leaving the split, instead of the next flow the parent draws, which is the one into it.
    • split type draws its exit flows only after all its branches are built, so each branch keeps its leftover handlers with its tail.
  • Behaviour change, for you to rule on: a handler on an activity without a result, in a branch that ends in return, now runs the rest of its branch, return included, rather than the code after the IF. That's the documented rule and the third shape in the issue. The IF no longer needs a merge for it, so thenNeedsErrorMerge / elseNeedsErrorMerge and addPendingErrorHandlerFlowTo (no callers left) are removed. If you'd rather keep the old routing for this case too, it's a one-line condition in addBranchErrorHandlerFlowForStatement.

Unchanged:

  • a handler on an activity before the split, including the empty-handler-into-ELSE pattern (pendingElseErrorOrigin);
  • a handler that ends in return or raise error;
  • an explicit join in the handler: the workaround shape from the comment above is stored identically (objects, positions, flows) by main and by this branch.

Also included: CHANGELOG (Fixed), a finding in findings/mdl-executor/, and mdl-examples/bug-tests/1565-branch-error-handler-rejoin.mdl.

Evidence

  • Tests: mdl/executor/cmd_microflows_builder_branch_handler_test.go, built from parsed MDL.
    • Ten branch shapes: IF without ELSE (with and without a result), THEN that returns, ELSE, handler on the branch's last activity, result used by the next branch statement, result used by the rest of a returning branch, nested IF, CASE, split type.
    • Each case asserts where the handler's tail reaches through merges, the number of end events, and that the flow graph has no cycle. All ten fail on main and pass here.
    • A further test checks that a handler before an IF still rejoins at the split. It passes on both.
  • Real run (Mendix 11.12.5 scratch app), plain mx check after exec:
    • The repro from the issue, the five microflows from the comment above, and eleven more probe shapes rejoin as in the table, in describe.
    • That includes the success-tail pattern of your test, which has the same flow structure on main and here. Only its merge position moves, from where the IF's merge was to the edge it splits.
    • mx check reports no new error. The CE0109 that main produced for the named-return case is gone.
    • describe → exec → describe is stable, and the second exec reports all documents unchanged.
  • Windows: go test ./mdl/executor/... fails the same 28 tests as unchanged main, and no others. go vet and gofmt are clean.
  • Linux (golang:1.26.6-bookworm, ANTLR 4.13.2): grammar, build, test, check-mdl, check-findings, check-conformance, check-migration-reference and lint-go all pass.

🤖 Generated with Claude Code

… failing activity (mendixlabs#1565)

A custom error handler that falls through, on an activity inside an IF,
CASE or split type branch, was wired outside the branch. With a result
variable it rejoined on the flow into the split, so a failing call
re-ran the decision and called again; without one it ended the
microflow at an end event of its own. In a branch ending in return it
rejoined after the IF. mx check accepts all three.

The builder keeps a handler pending until the next flow is drawn from
the failing activity. buildFlowGraph settles it on every flow it draws;
the branch loops settled only an empty handler. Each branch now settles
its handlers on its own flows and on its exit flow to the merge. The
handlers pending when a split starts belong to the activity before it,
so each branch sets them aside and restores them afterwards; otherwise
a result-variable handler, which settles on any flow, would rejoin in
the middle of the first branch.

In a branch that ends in return, a handler on an activity with a result
still goes to the code after the split, the shared error path of a
success tail built on the result
(TestBuildFlowGraph_OutputHandlerInReturningBranchSkipsDerivedSuccessTail).
Such a handler, and any other a branch cannot settle, is pinned to the
split's exit, so it rejoins on the flow leaving the split rather than
the next flow the parent draws, the one into it.

Behaviour change: a handler on an activity WITHOUT a result in a branch
that returns now runs the rest of its branch rather than the code after
the IF, so the IF no longer needs a merge for it (thenNeedsErrorMerge
and addPendingErrorHandlerFlowTo are removed).

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

Copy link
Copy Markdown
Contributor Author

Closing: fixed on main by 27e533f, which covers the same shapes and more. The ten branch cases and the before-the-IF guard from this PR's test all pass on main. Thanks!

@stagknee stagknee closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant