Skip to content

Commit 72f1f3a

Browse files
committed
fix: route branch-local output handlers to safe continuations
Symptom: a custom error handler inside a returning IF branch could be forced to terminate when the branch success tail declared values from the failed action output. The resulting MDL inserted a return into the handler, and the generated MPR no longer matched the original control flow. Root cause: branch merge detection only treated empty custom handlers as needing a merge, and output skip-var routing stopped at the first output-dependent statement in void microflows. Derived variables declared from the failed output and SHOW MESSAGE template arguments were also invisible to the skip-var scan. Fix: treat non-terminal custom handlers as branch merge inputs, route pending handlers from returning branches to that merge, carry skip-var state across DECLARE-derived variables, and include SHOW MESSAGE expressions in handler reference analysis. Tests: added a synthetic branch-level regression covering a custom handler that skips an output-derived success tail and rejoins at a shared error continuation. Ran make build, make test, and make lint-go.
1 parent 2930334 commit 72f1f3a

3 files changed

Lines changed: 238 additions & 8 deletions

File tree

mdl/executor/cmd_microflows_builder_control.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
3434
hasElseBody := len(s.ElseBody) > 0
3535
elseReturns := hasElseBody && lastStmtIsReturn(s.ElseBody)
3636
bothReturn := hasElseBody && thenReturns && elseReturns
37+
thenNeedsErrorMerge := thenReturns && bodyHasContinuingCustomErrorHandler(s.ThenBody)
38+
elseNeedsErrorMerge := elseReturns && bodyHasContinuingCustomErrorHandler(s.ElseBody)
3739

3840
// Save/restore endsWithReturn around branch processing to avoid
3941
// a branch's RETURN affecting the parent flow state prematurely
@@ -86,7 +88,7 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
8688
needMerge := false
8789
if !bothReturn {
8890
if hasElseBody {
89-
needMerge = !thenReturns && !elseReturns // both branches continue → 2 inputs
91+
needMerge = (!thenReturns && !elseReturns) || thenNeedsErrorMerge || elseNeedsErrorMerge
9092
} else {
9193
needMerge = !thenReturns // THEN continues + FALSE path → 2 inputs
9294
}
@@ -160,6 +162,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
160162
applyUserAnchors(flow, trueBranchAnchor, nil)
161163
fb.flows = append(fb.flows, flow)
162164
}
165+
} else if thenReturns && needMerge {
166+
fb.addPendingErrorHandlerFlowTo(mergeID)
163167
}
164168

165169
// Process ELSE body (below the THEN path)
@@ -208,6 +212,8 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
208212
applyUserAnchors(flow, prevElseAnchor, nil)
209213
fb.flows = append(fb.flows, flow)
210214
}
215+
} else if elseReturns && needMerge {
216+
fb.addPendingErrorHandlerFlowTo(mergeID)
211217
}
212218
} else {
213219
// IF WITHOUT ELSE: FALSE path horizontal (happy path), TRUE path below
@@ -322,6 +328,71 @@ func (fb *flowBuilder) addIfStatement(s *ast.IfStmt) model.ID {
322328
return splitID
323329
}
324330

331+
func bodyHasContinuingCustomErrorHandler(stmts []ast.MicroflowStatement) bool {
332+
for _, stmt := range stmts {
333+
switch s := stmt.(type) {
334+
case *ast.CallMicroflowStmt:
335+
if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) {
336+
return true
337+
}
338+
case *ast.CallJavaActionStmt:
339+
if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) {
340+
return true
341+
}
342+
case *ast.RestCallStmt:
343+
if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) {
344+
return true
345+
}
346+
case *ast.ImportFromMappingStmt:
347+
if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) {
348+
return true
349+
}
350+
case *ast.CreateObjectStmt:
351+
if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) {
352+
return true
353+
}
354+
case *ast.MfCommitStmt:
355+
if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) {
356+
return true
357+
}
358+
case *ast.DeleteObjectStmt:
359+
if isContinuingCustomErrorHandler(s.ErrorHandling) || bodyHasContinuingCustomErrorHandler(errorBody(s.ErrorHandling)) {
360+
return true
361+
}
362+
case *ast.IfStmt:
363+
if bodyHasContinuingCustomErrorHandler(s.ThenBody) || bodyHasContinuingCustomErrorHandler(s.ElseBody) {
364+
return true
365+
}
366+
case *ast.LoopStmt:
367+
if bodyHasContinuingCustomErrorHandler(s.Body) {
368+
return true
369+
}
370+
case *ast.WhileStmt:
371+
if bodyHasContinuingCustomErrorHandler(s.Body) {
372+
return true
373+
}
374+
}
375+
}
376+
return false
377+
}
378+
379+
func isContinuingCustomErrorHandler(eh *ast.ErrorHandlingClause) bool {
380+
if eh == nil {
381+
return false
382+
}
383+
if eh.Type != ast.ErrorHandlingCustom && eh.Type != ast.ErrorHandlingCustomWithoutRollback {
384+
return false
385+
}
386+
return len(eh.Body) == 0 || !lastStmtIsReturn(eh.Body)
387+
}
388+
389+
func errorBody(eh *ast.ErrorHandlingClause) []ast.MicroflowStatement {
390+
if eh == nil {
391+
return nil
392+
}
393+
return eh.Body
394+
}
395+
325396
// addLoopStatement creates a LOOP statement using LoopedActivity.
326397
// Layout: Auto-sizes the loop box to fit content with padding
327398
func (fb *flowBuilder) addLoopStatement(s *ast.LoopStmt) model.ID {

mdl/executor/cmd_microflows_builder_flows.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,27 @@ func (fb *flowBuilder) addPendingErrorHandlerFlowForStatement(originID, destinat
127127
})
128128
}
129129

130+
func (fb *flowBuilder) addPendingErrorHandlerFlowTo(destinationID model.ID) {
131+
if destinationID == "" {
132+
return
133+
}
134+
fb.rewritePendingErrorHandlers(func(state pendingErrorHandlerState) pendingErrorHandlerState {
135+
if state.emptyFrom != "" {
136+
fb.addEmptyErrorHandlerRejoinFlowFrom(state.emptyFrom, state.emptyFrom, destinationID)
137+
state.emptyFrom = ""
138+
}
139+
if state.source != "" && state.tailFrom != "" {
140+
fb.addErrorHandlerRejoinFlowForState(state, state.source, destinationID)
141+
state.source = ""
142+
state.tailFrom = ""
143+
state.skipVar = ""
144+
state.tailIsSource = false
145+
state.returnValue = ""
146+
}
147+
return state
148+
})
149+
}
150+
130151
func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHandlerState, originID, destinationID model.ID, stmt ast.MicroflowStatement, futureReferencesSkipVar bool) pendingErrorHandlerState {
131152
if destinationID == "" {
132153
return state
@@ -147,13 +168,10 @@ func (fb *flowBuilder) addPendingErrorHandlerFlowForState(state pendingErrorHand
147168
if state.skipVar != "" {
148169
if statementReferencesVar(stmt, state.skipVar) {
149170
if !fb.hasReturnValue {
150-
endID := fb.addTerminalEndEventForPendingHandler(fb.returnType, "")
151-
if state.tailIsSource {
152-
fb.flows = append(fb.flows, newErrorHandlerFlow(state.tailFrom, endID))
153-
} else {
154-
fb.flows = append(fb.flows, newHorizontalFlow(state.tailFrom, endID))
171+
if derivedVar := outputDerivedVariable(stmt, state.skipVar); derivedVar != "" {
172+
state.skipVar = derivedVar
155173
}
156-
return pendingErrorHandlerState{}
174+
return state
157175
}
158176
return state
159177
}
@@ -332,6 +350,11 @@ func errorHandlerStatementVarRefs(stmt ast.MicroflowStatement) []string {
332350
for _, param := range s.Template {
333351
refs = append(refs, exprVarRefs(param.Value)...)
334352
}
353+
case *ast.ShowMessageStmt:
354+
refs = append(refs, exprVarRefs(s.Message)...)
355+
for _, arg := range s.TemplateArgs {
356+
refs = append(refs, exprVarRefs(arg)...)
357+
}
335358
case *ast.IfStmt:
336359
refs = append(refs, exprVarRefs(s.Condition)...)
337360
refs = append(refs, errorHandlerStatementsVarRefs(s.ThenBody)...)
@@ -397,6 +420,19 @@ func errorHandlerStatementVarRefs(stmt ast.MicroflowStatement) []string {
397420
return refs
398421
}
399422

423+
func outputDerivedVariable(stmt ast.MicroflowStatement, sourceVar string) string {
424+
declare, ok := stmt.(*ast.DeclareStmt)
425+
if !ok || declare.Variable == "" {
426+
return ""
427+
}
428+
for _, ref := range exprVarRefs(declare.InitialValue) {
429+
if ref == sourceVar {
430+
return declare.Variable
431+
}
432+
}
433+
return ""
434+
}
435+
400436
func errorHandlerStatementsVarRefs(stmts []ast.MicroflowStatement) []string {
401437
var refs []string
402438
for _, stmt := range stmts {

mdl/executor/cmd_microflows_builder_terminal_test.go

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package executor
44

55
import (
6+
"strings"
67
"testing"
78

89
"github.com/mendixlabs/mxcli/mdl/ast"
@@ -280,7 +281,7 @@ func TestBuildFlowGraph_OutputHandlerTerminatesBeforeDeclareReferencingOutput(t
280281
body := []ast.MicroflowStatement{
281282
&ast.CallMicroflowStmt{
282283
OutputVariable: "CreatedRecord",
283-
MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "CreateRecord"},
284+
MicroflowName: ast.QualifiedName{Module: "SampleSync", Name: "CreateRecord"},
284285
ErrorHandling: &ast.ErrorHandlingClause{
285286
Type: ast.ErrorHandlingCustomWithoutRollback,
286287
Body: []ast.MicroflowStatement{
@@ -348,6 +349,128 @@ func TestBuildFlowGraph_OutputHandlerTerminatesBeforeDeclareReferencingOutput(t
348349
}
349350
}
350351

352+
func TestBuildFlowGraph_OutputHandlerInReturningBranchSkipsDerivedSuccessTail(t *testing.T) {
353+
successMessage := &ast.VariableExpr{Name: "SuccessMessage"}
354+
body := []ast.MicroflowStatement{
355+
&ast.DeclareStmt{
356+
Variable: "ErrorMessage",
357+
Type: ast.DataType{Kind: ast.TypeString},
358+
InitialValue: &ast.LiteralExpr{Kind: ast.LiteralString, Value: ""},
359+
},
360+
&ast.IfStmt{
361+
Condition: &ast.VariableExpr{Name: "CanCreate"},
362+
ThenBody: []ast.MicroflowStatement{
363+
&ast.CallMicroflowStmt{
364+
OutputVariable: "CreatedRecord",
365+
MicroflowName: ast.QualifiedName{Module: "SampleService", Name: "CreateRecord"},
366+
ErrorHandling: &ast.ErrorHandlingClause{
367+
Type: ast.ErrorHandlingCustomWithoutRollback,
368+
Body: []ast.MicroflowStatement{
369+
&ast.MfSetStmt{
370+
Target: "ErrorMessage",
371+
Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "create failed"},
372+
},
373+
},
374+
},
375+
},
376+
&ast.DeclareStmt{
377+
Variable: "SuccessMessage",
378+
Type: ast.DataType{Kind: ast.TypeString},
379+
InitialValue: &ast.BinaryExpr{
380+
Left: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "Created "},
381+
Operator: "+",
382+
Right: &ast.AttributePathExpr{Variable: "CreatedRecord", Path: []string{"Name"}},
383+
},
384+
},
385+
&ast.LogStmt{
386+
Level: ast.LogInfo,
387+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"},
388+
Template: []ast.TemplateParam{
389+
{Index: 1, Value: successMessage},
390+
},
391+
},
392+
&ast.ShowMessageStmt{
393+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"},
394+
Type: "Information",
395+
TemplateArgs: []ast.Expression{successMessage},
396+
},
397+
&ast.ClosePageStmt{},
398+
&ast.ReturnStmt{},
399+
},
400+
ElseBody: []ast.MicroflowStatement{
401+
&ast.MfSetStmt{
402+
Target: "ErrorMessage",
403+
Value: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "not found"},
404+
},
405+
},
406+
},
407+
&ast.LogStmt{
408+
Level: ast.LogError,
409+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"},
410+
Template: []ast.TemplateParam{
411+
{Index: 1, Value: &ast.VariableExpr{Name: "ErrorMessage"}},
412+
},
413+
},
414+
&ast.ShowMessageStmt{
415+
Message: &ast.LiteralExpr{Kind: ast.LiteralString, Value: "{1}"},
416+
Type: "Error",
417+
TemplateArgs: []ast.Expression{&ast.VariableExpr{Name: "ErrorMessage"}},
418+
},
419+
&ast.ReturnStmt{},
420+
}
421+
422+
if !statementReferencesVar(body[1].(*ast.IfStmt).ThenBody[3], "SuccessMessage") {
423+
t.Fatal("SHOW MESSAGE arguments must be visible to custom handler skip-var routing")
424+
}
425+
426+
fb := &flowBuilder{
427+
posX: 100,
428+
posY: 100,
429+
spacing: HorizontalSpacing,
430+
declaredVars: map[string]string{"CanCreate": "Boolean"},
431+
measurer: &layoutMeasurer{},
432+
}
433+
oc := fb.buildFlowGraph(body, nil)
434+
435+
var callID, handlerSetID, successDeclareID, errorLogID model.ID
436+
for _, obj := range oc.Objects {
437+
activity, ok := obj.(*microflows.ActionActivity)
438+
if !ok {
439+
continue
440+
}
441+
switch action := activity.Action.(type) {
442+
case *microflows.MicroflowCallAction:
443+
if action.ResultVariableName == "CreatedRecord" {
444+
callID = activity.ID
445+
}
446+
case *microflows.ChangeVariableAction:
447+
if action.VariableName == "ErrorMessage" && strings.Contains(action.Value, "create failed") {
448+
handlerSetID = activity.ID
449+
}
450+
case *microflows.CreateVariableAction:
451+
if action.VariableName == "SuccessMessage" {
452+
successDeclareID = activity.ID
453+
}
454+
case *microflows.LogMessageAction:
455+
if action.LogLevel == "Error" {
456+
errorLogID = activity.ID
457+
}
458+
}
459+
}
460+
if callID == "" || handlerSetID == "" || successDeclareID == "" || errorLogID == "" {
461+
t.Fatalf("expected source call, handler set, success declare, and error log; got call=%q handler=%q declare=%q errorLog=%q", callID, handlerSetID, successDeclareID, errorLogID)
462+
}
463+
if !flowPathExists(oc.Flows, callID, handlerSetID) {
464+
t.Fatal("source call must connect to the custom handler body")
465+
}
466+
if flowPathExists(oc.Flows, handlerSetID, successDeclareID) {
467+
t.Fatal("custom handler must skip the output-derived success tail")
468+
}
469+
if !flowPathExists(oc.Flows, handlerSetID, errorLogID) {
470+
t.Fatal("custom handler in a returning branch must rejoin at the shared safe continuation")
471+
}
472+
}
473+
351474
func TestBuildFlowGraph_EmptyNoOutputHandlerRejoinsAtNextAction(t *testing.T) {
352475
body := []ast.MicroflowStatement{
353476
&ast.CallMicroflowStmt{

0 commit comments

Comments
 (0)