Skip to content

A fall-through error handler on an activity inside an IF branch is wired to the wrong place: an endless loop with a result variable, a silent end without one #1565

Open

Description

@stagknee

Repro (v0.26.0, current main, and #1564; headerless and with mdl 1;; Mendix 11.12.5)

create or modify microflow Shop.ZZ_Callee () returns Integer begin return 1; end;
create or modify microflow Shop.ZZ_Callee0 () begin log info node 'Probe' 'callee'; end;

-- A: the call assigns a result
create or modify microflow Shop.ZZ_E1 ($Flag: Boolean)
begin
  log info node 'Probe' 'before';
  if $Flag then
    $N = call microflow Shop.ZZ_Callee () on error without rollback begin
      log warning node 'Probe' 'failed';
    end error;
    log info node 'Probe' 'after call';
  end if;
  log info node 'Probe' 'end';
end;

-- B: the same without a result variable
create or modify microflow Shop.ZZ_E2 ($Flag: Boolean)
begin
  log info node 'Probe' 'before';
  if $Flag then
    call microflow Shop.ZZ_Callee0 () on error without rollback begin
      log warning node 'Probe' 'failed';
    end error;
    log info node 'Probe' 'after call';
  end if;
  log info node 'Probe' 'end';
end;

check and exec pass, and mx check reports 0 errors for both. The documented rule says a handler that doesn't end in RETURN or RAISE ERROR continues with the statement after the failing activity, which here is 'after call'. What gets stored instead:

  • A, an endless loop. The handler's last activity flows into a new merge that sits in front of the $Flag split. A failed call re-enters the decision, takes the same branch and calls again, for as long as the call keeps failing. describe shows merge rejoin1; before the if and join rejoin1; in the handler.
  • B, a silent end. The handler's last activity flows into a new EndEvent. 'after call' and 'end' never run after a failure. describe prints a return; inside the handler that the source never wrote.

The same thing happens:

  • in either branch of an IF/ELSE;
  • for $Obj = create … with a handler;
  • for a handler on the last statement of a branch.

An empty handler, on error continue, a handler ending in RETURN, and a fall-through handler at the top level of the flow are all wired correctly.

A third shape, for you to rule on. In an IF/ELSE where the THEN branch ends in RETURN and the handler is in THEN while ELSE falls through, the handler rejoins at the IF's exit merge. On failure it runs the code after the IF and skips the rest of the THEN branch, including its return. That is deliberate code (thenNeedsErrorMerge), but it also contradicts the documented rule.

Cause

errorHandlerSource, errorHandlerTailFrom and errorHandlerSkipVar (cmd_microflows_builder.go) form one builder-wide pending-handler slot. At the top level the slot is settled when the next flow leaves the failing activity: buildFlowGraph calls addPendingErrorHandlerFlowForStatement.

In addIfStatement (cmd_microflows_builder_control.go), the branch loops only settle the empty-handler state: if fb.emptyErrorHandlerFrom == lastThenID, near lines 194 and 299, and the same in the enum-split loop. The IF-without-ELSE THEN loop has no such call at all. A non-empty handler's pending state therefore leaks out of the IF, and the outer builder consumes it:

  • With a result variable (A): the outer loop draws log → split and calls addPendingErrorHandlerFlowForStatement(log, split, …). Because skipVar is set, addPendingErrorHandlerFlowForState (cmd_microflows_builder_flows.go, around 342–355) splices a merge into that unrelated flow. The state.source == originID guard (around line 358) is only on the non-skipVar path.
  • Without one (B): nothing matches, so terminatePendingErrorHandlersAtEnd (cmd_microflows_builder_graph.go, around 564) gives the handler a fresh EndEvent.

Suggested fix and a stopgap

Fix. Settle a non-empty handler's pending state inside each branch loop, keyed on errorHandlerSource == lastID and not only on emptyErrorHandlerFrom. Close it at the branch's exit, and add the source == originID guard to the skipVar path. A round-trip test is needed for both variants and for nested IFs.

Stopgap. Until then, check could refuse a non-empty fall-through handler on an activity inside an IF/ELSE or enum-split branch. bodyHasContinuingCustomErrorHandler and isContinuingCustomErrorHandler already detect the shape. A silent endless loop that mx check accepts is worse than a refusal.

Workaround: inside a branch, end the handler with RETURN and copy the remaining branch steps into it, or move the call into a sub-microflow called at the top level.

I can open a PR for this if you'd like. It's in the area you're working in this week (#1559), so tell me if you'd rather take it.

Activity

  1. github-actions commented on Oct 17, 2026

    @github-actions

    Issue Triage

    Summary:
    The issue describes a bug where a fall‑through error handler inside an IF/ELSE branch is incorrectly wired, causing either an endless loop (when the microflow assigns a result) or a silent early end (when it does not), contrary to the documented behavior that the handler should continue with the statement after the failing activity.

    Checklist for bug report completeness

    Required information Present? Details
    mxcli version ✅ v0.26.0 (also mentions current main and PR #1564)
    Mendix version ✅ 11.12.5
    Scenario (what the user was trying to do) ✅ Provided MDL script defining two microflows (ZZ_E1 and ZZ_E2) and ran mx check / mx exec
    Expected output ✅ Handler should continue after the failing activity, executing the 'after call' and 'end' log statements
    Experienced output ✅ - With result variable: endless loop (handler re‑enters the $Flag split)
    - Without result variable: silent end (handler jumps to an EndEvent, skipping 'after call' and 'end')
    AI bug report (mxcli diag --bundle or session log) ❌ Not included

    Missing information
    To help us reproduce and diagnose the issue, could you please provide the output of mxcli diag --bundle (or at least the relevant session log) for the commands you ran? This will give us the internal trace needed to verify the handler wiring.

    Thank you for the detailed report—once we have the diagnostic bundle we can move forward with investigating the fix.


    Automated triage via OpenRouter — workflow source

  2. ynnckw commented on Oct 19, 2026

    @ynnckw

    Reproduced on v0.27.0, Mendix 11.12.4; v0.26.0 stores the same flows. I read the stored sequence flows of each microflow. Both shapes from the report show up, in the ELSE branch too. Three more cases may help with the fix and its round-trip tests.

    1. A REST call behaves the same way, and the result variable decides the shape. With $Response = call rest service … returns response, a fall-through handler inside an IF leads into a new merge in front of the $Flag split. With returns nothing, it leads to a new empty end event instead. This matches $N = call microflow … and call microflow … in the report.

    2. In a flow with a return value, the shape without a result depends on whether the return value is named.

    • With returns Boolean as $Result, the handler's new end event returns $Result, a variable that does not exist, and mx check reports CE0109. Of the shapes here, this is the only one any check reports.
    • With returns Boolean and no name, the handler leads into a new merge in front of the flow's final return true;. After a failure the flow skips the rest of the branch and everything after the IF, and still returns true. mx check reports nothing.
    • With a result variable, the handler loops back in front of the split in a flow with a return value as well.

    3. An explicit join to a merge right after the failing activity wires the handler as the documented rule describes. This is a workaround until the fix lands, and it may also be the shape the builder could generate.

    Steps to reproduce

    mdl 1;
    create module MyModule;
    
    create microflow MyModule.CalleeVoid ()
    begin
      log info node 'Probe' 'callee';
    end;
    
    -- 1. REST call with a result, inside an IF
    create microflow MyModule.RestInIf ($Flag: Boolean)
    begin
      log info node 'Probe' 'before';
      if $Flag then
        $Response = call rest service get 'https://example.com/x' (
          Timeout: 30,
        ) returns response
          on error without rollback begin
            log warning node 'Probe' 'failed';
          end error;
        log info node 'Probe' 'after call';
      end if;
      log info node 'Probe' 'end';
    end;
    
    -- 2. The same with returns nothing
    create microflow MyModule.RestNothingInIf ($Flag: Boolean)
    begin
      log info node 'Probe' 'before';
      if $Flag then
        call rest service get 'https://example.com/x' (
          Timeout: 30,
        ) returns nothing
          on error without rollback begin
            log warning node 'Probe' 'failed';
          end error;
        log info node 'Probe' 'after call';
      end if;
      log info node 'Probe' 'end';
    end;
    
    -- 3. A flow with a named return value, call without a result
    create microflow MyModule.CallInIfNamed ($Flag: Boolean) returns Boolean as $Result
    begin
      log info node 'Probe' 'before';
      if $Flag then
        call microflow MyModule.CalleeVoid ()
          on error without rollback begin
            log warning node 'Probe' 'failed';
          end error;
        log info node 'Probe' 'after call';
      end if;
      log info node 'Probe' 'end';
      return true;
    end;
    
    -- 4. The same without a name for the return value
    create microflow MyModule.CallInIfUnnamed ($Flag: Boolean) returns Boolean
    begin
      log info node 'Probe' 'before';
      if $Flag then
        call microflow MyModule.CalleeVoid ()
          on error without rollback begin
            log warning node 'Probe' 'failed';
          end error;
        log info node 'Probe' 'after call';
      end if;
      log info node 'Probe' 'end';
      return true;
    end;
    
    -- 5. Workaround: an explicit join to a merge right after the failing activity
    create microflow MyModule.RestInIfJoin ($Flag: Boolean)
    begin
      log info node 'Probe' 'before';
      if $Flag then
        $Response = call rest service get 'https://example.com/x' (
          Timeout: 30,
        ) returns response
          on error without rollback begin
            log warning node 'Probe' 'failed';
            join afterCall;
          end error;
        merge afterCall;
        log info node 'Probe' 'after call';
      end if;
      log info node 'Probe' 'end';
    end;

    check -p … --references prints "Check passed!", and exec creates all six microflows without a warning.

    Expected

    In 1 to 4 the handler continues with log 'after call', as it does in 5. This is the rule in mxcli syntax microflow error-handling: "A handler that does NOT end in RETURN/RAISE ERROR merges back into the main flow".

    Actual, read from the stored sequence flows; none of the microflows was run:

    Microflow The handler's log 'failed' leads to mx check -w
    RestInIf a new merge spliced between log 'before' and the $Flag split, so a lasting failure calls again and again nothing
    RestNothingInIf a new empty end event nothing
    CallInIfNamed a new end event returning $Result [error] [CE0109] "Undefined variable 'Result'." at End event
    CallInIfUnnamed a new merge in front of the final end event (return true) nothing
    RestInIfJoin the merge afterCall, then log 'after call' nothing

    On the project after exec, mx check -w reports that one CE0109 and no other new error or warning.

    describe microflow prints what was stored (@position lines removed):

    -- RestInIf
      log node 'Probe' 'before';
      merge rejoin1;
      if $Flag then
        $Response = call rest service get 'https://example.com/x' (
        Timeout: 30,
      ) returns response on error without rollback begin
          log warning node 'Probe' 'failed';
          join rejoin1;
        end error;
    
    -- RestNothingInIf
      ) returns nothing on error without rollback begin
          log warning node 'Probe' 'failed';
          return;
        end error;
    
    -- CallInIfUnnamed
        call microflow MyModule.CalleeVoid() on error without rollback begin
          log warning node 'Probe' 'failed';
          join rejoin1;
        end error;
        log node 'Probe' 'after call';
      end if;
      log node 'Probe' 'end';
      merge rejoin1;
      return true;

    A handler that ends in return; (void flow) or return false; (flow with a return value) is stored correctly in the same positions, REST calls included.

    Possibly related: #1558, an if without else inside an on error handler, whose false flow is stored with NoCase. The nesting there is the other way round (an IF inside a handler, here a handler inside an IF), and the symptom differs. Both come from how the IF builder treats custom error handlers, so one round-trip test could cover both.

    Environment: mxcli v0.27.0 (2026-10-15) and v0.26.0, Mendix 11.12.4, Windows.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions