Skip to content

Commit 52d0f33

Browse files
fix(runtime): report transition choices only for the transition that fires
Transition choice and guard-unevaluable notes ride on the dispatch candidate and are recorded when it fires, so a transition several regions select through their enclosing state is one choice and a composite state's transitions outranked by a nested one report nothing. An action step that fails after a token already went still records the order it took, including the failing token. Co-Authored-By: jason.han <hanhuijun@gmail.com>
1 parent a3ad63f commit 52d0f33

17 files changed

Lines changed: 403 additions & 76 deletions

docs/project/behavior-semantic-oracle.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,11 @@ Derived constraints:
284284
Open: which enabled transition fires. UML orders a transition on a descendant state before one
285285
on its ancestor (the case `state_choice_ancestor_priority_not_reported` pins that rule, and the
286286
executor does not report it as a choice); between two transitions on the *same* state nothing
287-
in the library or the specification ranks them.
287+
in the library or the specification ranks them. The choice is the firing transition's: the
288+
regions of a parallel state that select the same transition out of it make one choice, reported
289+
once (`state_choice_shared_ancestor_regions`), and a composite state's transitions that lose to a
290+
nested one were never chosen among, so nothing about them is reported
291+
(`state_choice_ancestor_outranked_not_reported`).
288292

289293
Pinned outcome: the admissible set `{route = 1 in low, route = 2 in high}`, stated as `outcomes`
290294
citing this section. The executor examines every transition out of the state for the event,

docs/project/spec-compliance.md

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

docs/reference/wire-contract.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -776,8 +776,10 @@ time it is entered, so a state entered twice appears twice. `diagnostics` carrie
776776
entry for each event that enabled several transitions out of one state, located at the
777777
transition taken, as `ExecuteAction`'s does for its steps, and a `guard not evaluable: <state> on
778778
<trigger>: transition <n>-><target>: <failure> (not selected)` entry for a transition after the
779-
first enabled one whose guard it could not evaluate in its preview; a transition on a substate
780-
beating one on the state enclosing it is spec-defined order and is not reported. For `state def Hub { entry;
779+
first enabled one whose guard it could not evaluate in its preview. Both belong to the transition
780+
that fires: a transition out of a parallel state that several of its regions select is one entry,
781+
and a transition on a substate beating one on the state enclosing it is spec-defined order, so
782+
nothing about the beaten state's transitions is reported. For `state def Hub { entry;
781783
then Idle; state Idle; state A; state B; transition first Idle accept Go then A; transition first
782784
Idle accept Go then B; }` in the same document:
783785

internal/core/runtime/action_choice.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,16 +145,15 @@ func (e *ActionExecutor) beginStepOrder() stepOrder {
145145

146146
// stepTokenNoting steps the token at index i and notes it in order when it did
147147
// something it could have done first (not parked, and not enabled by this step).
148+
// A step that fails was still the token's turn, so the order taken is complete.
148149
func (e *ActionExecutor) stepTokenNoting(i int, order *stepOrder) error {
149150
before := e.tokens[i]
150151
count := len(e.tokens)
151-
if err := e.stepToken(i); err != nil {
152-
return err
153-
}
154-
if order.eligible(before) && e.tokenActed(before, count) {
152+
err := e.stepToken(i)
153+
if order.eligible(before) && (err != nil || e.tokenActed(before, count)) {
155154
order.acted = append(order.acted, before)
156155
}
157-
return nil
156+
return err
158157
}
159158

160159
// eligible reports whether the token could have gone first in the step and is

internal/core/runtime/action_executor.go

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,6 @@ func (e *ActionExecutor) Step() error {
242242
tokenLocationsBefore[i] = t.Location
243243
}
244244

245-
// Collect token indices to step (snapshot before iteration)
246-
tokenIndices := make([]int, len(e.tokens))
247-
for i := range e.tokens {
248-
tokenIndices[i] = i
249-
}
250-
251245
// The tokens a breakpoint left paused resume last, the longest paused first,
252246
// once every other token has had its step: one pausing again and again does
253247
// not hold the rest back.
@@ -258,32 +252,13 @@ func (e *ActionExecutor) Step() error {
258252
order := e.beginStepOrder()
259253
defer e.beginStepWrites(e.stepCount + 1)()
260254

261-
// Step tokens in reverse order to handle removal safely
262-
// (removing token at higher index doesn't affect lower indices)
263-
for i := len(tokenIndices) - 1; i >= 0 && e.state != StateSuspended; i-- {
264-
// Check if token still exists (may have been removed by join/final)
265-
if i >= len(e.tokens) || e.moving(e.tokens[i]) ||
266-
e.tokens[i].drivenByBody() || e.tokens[i].body != nil {
267-
continue
268-
}
269-
270-
if err := e.stepTokenNoting(i, &order); err != nil {
271-
e.endPausedBodies()
272-
return err
273-
}
274-
}
275-
for _, id := range paused {
276-
if e.state == StateSuspended {
277-
break
278-
}
279-
if i := e.tokenIndex(id); i >= 0 {
280-
if err := e.stepTokenNoting(i, &order); err != nil {
281-
e.endPausedBodies()
282-
return err
283-
}
284-
}
285-
}
255+
err := e.stepTokens(len(e.tokens), paused, &order)
256+
// The order the tokens took is a fact of the step whether or not it failed.
286257
e.noteTokenOrder(e.stepCount+1, order)
258+
if err != nil {
259+
e.endPausedBodies()
260+
return err
261+
}
287262

288263
// A step a breakpoint ends leaves every other token where it was, yet the run
289264
// went on.
@@ -1122,6 +1097,33 @@ func (e *ActionExecutor) probeGuard(frame *actionFrame, node *ast.DecisionNode,
11221097
return result.Const.Bool
11231098
}
11241099

1100+
// stepTokens gives each of the first count tokens its step, highest index first
1101+
// so a removal leaves the lower indices in place, then the tokens a breakpoint
1102+
// left paused; a breakpoint reached on the way ends the sweep.
1103+
func (e *ActionExecutor) stepTokens(count int, paused []int64, order *stepOrder) error {
1104+
for i := count - 1; i >= 0 && e.state != StateSuspended; i-- {
1105+
// The token may have been removed by a join or final node.
1106+
if i >= len(e.tokens) || e.moving(e.tokens[i]) ||
1107+
e.tokens[i].drivenByBody() || e.tokens[i].body != nil {
1108+
continue
1109+
}
1110+
if err := e.stepTokenNoting(i, order); err != nil {
1111+
return err
1112+
}
1113+
}
1114+
for _, id := range paused {
1115+
if e.state == StateSuspended {
1116+
break
1117+
}
1118+
if i := e.tokenIndex(id); i >= 0 {
1119+
if err := e.stepTokenNoting(i, order); err != nil {
1120+
return err
1121+
}
1122+
}
1123+
}
1124+
return nil
1125+
}
1126+
11251127
// stepInitialNode advances token from initial node to successors.
11261128
func (e *ActionExecutor) stepInitialNode(tokenIdx int) error {
11271129
token := &e.tokens[tokenIdx]

internal/core/runtime/action_subflow.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,15 +102,17 @@ func (e *ActionExecutor) stepSubflow(perf *actionFrame) (bool, error) {
102102
defer e.beginSweep()()
103103
order := e.beginStepOrder()
104104
defer e.beginStepWrites(e.stepCount + 1)()
105-
for i := len(e.tokens) - 1; i >= 0; i-- {
105+
var err error
106+
for i := len(e.tokens) - 1; i >= 0 && err == nil; i-- {
106107
if i >= len(e.tokens) || e.moving(e.tokens[i]) || !e.tokens[i].inFlowOf(perf) {
107108
continue
108109
}
109-
if err := e.stepTokenNoting(i, &order); err != nil {
110-
return false, err
111-
}
110+
err = e.stepTokenNoting(i, &order)
112111
}
113112
e.noteTokenOrder(e.stepCount+1, order)
113+
if err != nil {
114+
return false, err
115+
}
114116
after := e.subflowLocations(perf)
115117
if len(after) != len(before) {
116118
return true, nil

internal/core/runtime/choice.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,13 @@ func (ctx *Context) noteUnevaluableGuard(g UnevaluableGuard) {
180180
ctx.note(g)
181181
}
182182

183+
// noteAll records notes in order.
184+
func (ctx *Context) noteAll(notes []RunNote) {
185+
for _, n := range notes {
186+
ctx.note(n)
187+
}
188+
}
189+
183190
// note keeps n for the run's diagnostics and, when tracing, writes it to the
184191
// trace where it was made. A probe's preview is not a run.
185192
func (ctx *Context) note(n RunNote) {

internal/core/runtime/choice_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,135 @@ func TestAncestorPriorityIsNotAChoice(t *testing.T) {
380380
}
381381
}
382382

383+
// Leaves in sibling regions select the same transition out of the composite
384+
// state enclosing them, which fires once: so does the choice among the
385+
// transitions out of it.
386+
func TestSharedAncestorChoiceIsReportedOnce(t *testing.T) {
387+
src := `package test {
388+
state Machine {
389+
attribute level : Integer = 8;
390+
entry; then work;
391+
state work parallel {
392+
state a { entry; then a1; state a1; }
393+
state b { entry; then b1; state b1; }
394+
}
395+
state low;
396+
state high;
397+
transition first work accept Go if level > 5 then low;
398+
transition first work accept Go if level > 7 then high;
399+
transition first work accept Go if 1 / (level - 8) > 0 then high;
400+
}
401+
}`
402+
idx, _, ctx := buildRuntime(t, "<test>", parseAndBuild(t, src))
403+
sym := findSymbolByName(idx.DocumentRoot("<test>"), "Machine", ast.DefState)
404+
if sym == nil {
405+
t.Fatal("state machine not found")
406+
}
407+
_, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"})
408+
if err != nil {
409+
t.Fatalf("execute: %v", err)
410+
}
411+
if strings.Join(visited, ",") != "work,a1,b1,low" {
412+
t.Fatalf("visited %v, want both regions entered, then the first transition out of work", visited)
413+
}
414+
want := "choice state work on accept Go: transitions 1->low, 2->high (unordered; took 1->low)"
415+
if got := ctx.Choices(); len(got) != 1 || got[0].String() != want {
416+
t.Fatalf("choices = %v, want exactly [%s]", got, want)
417+
}
418+
if got := ctx.UnevaluableGuards(); len(got) != 1 || got[0].Alternative != "3->high" {
419+
t.Fatalf("unevaluable guards = %v, want the third transition out of work, once", got)
420+
}
421+
}
422+
423+
// A transition out of a composite state loses to one a nested state takes on the
424+
// same event, so the alternatives found out of the composite state were never
425+
// the run's to choose among: nothing about them is reported.
426+
func TestAncestorChoiceSuppressedByNestedTransitionIsNotReported(t *testing.T) {
427+
src := `package test {
428+
state Machine {
429+
attribute level : Integer = 8;
430+
entry; then work;
431+
state work parallel {
432+
state a {
433+
entry; then a1;
434+
state a1;
435+
state a2;
436+
transition first a1 accept Go then a2;
437+
}
438+
state b { entry; then b1; state b1; }
439+
}
440+
state low;
441+
state high;
442+
transition first work accept Go if level > 5 then low;
443+
transition first work accept Go if level > 7 then high;
444+
transition first work accept Go if 1 / (level - 8) > 0 then high;
445+
}
446+
}`
447+
idx, _, ctx := buildRuntime(t, "<test>", parseAndBuild(t, src))
448+
sym := findSymbolByName(idx.DocumentRoot("<test>"), "Machine", ast.DefState)
449+
if sym == nil {
450+
t.Fatal("state machine not found")
451+
}
452+
_, visited, err := ctx.ExecuteStateWithEvents(sym, []string{"Go"})
453+
if err != nil {
454+
t.Fatalf("execute: %v", err)
455+
}
456+
if strings.Join(visited, ",") != "work,a1,b1,a2" {
457+
t.Fatalf("visited %v, want the nested transition to fire and work to stay active", visited)
458+
}
459+
if got := ctx.Notes(); len(got) != 0 {
460+
t.Fatalf("the outranked transitions out of work were reported: %v", got)
461+
}
462+
}
463+
464+
// A step that fails after one token already went still made an ordering choice:
465+
// the failing token could have gone first, and the diagnostics of a failed run
466+
// must say what the run did before it failed.
467+
func TestTokenOrderIsReportedWhenALaterTokenFails(t *testing.T) {
468+
src := `package test {
469+
private import ScalarValues::*;
470+
action race {
471+
attribute x : Integer = 0;
472+
attribute n : Integer = 0;
473+
first start;
474+
fork split;
475+
action safe { assign x := 1; }
476+
action failing { assign x := 1 / n; }
477+
join sync;
478+
done;
479+
succession first start then split;
480+
succession first split then failing;
481+
succession first split then safe;
482+
succession first safe then sync;
483+
succession first failing then sync;
484+
succession first sync then done;
485+
}
486+
}`
487+
idx, _, ctx := buildRuntime(t, "<test>", parseAndBuild(t, src))
488+
sym := findSymbolByName(idx.DocumentRoot("<test>"), "race", ast.DefAction)
489+
if sym == nil {
490+
t.Fatal("action not found")
491+
}
492+
_, err := ctx.ExecuteAction(sym)
493+
if err == nil || !strings.Contains(err.Error(), "division by zero") {
494+
t.Fatalf("err = %v, want the failing token's error", err)
495+
}
496+
var tokenOrders []ChoicePoint
497+
for _, c := range ctx.Choices() {
498+
if c.Kind == ChoiceTokenOrder {
499+
tokenOrders = append(tokenOrders, c)
500+
}
501+
}
502+
if len(tokenOrders) != 1 {
503+
t.Fatalf("token-order choices = %v, want the one step both tokens took part in", tokenOrders)
504+
}
505+
got := tokenOrders[0]
506+
if len(got.Alternatives) != 2 || !strings.HasSuffix(got.Alternatives[0], "@failing") ||
507+
!strings.HasSuffix(got.Alternatives[1], "@safe") || got.Taken != 1 {
508+
t.Fatalf("choice = %s, want failing and safe as the alternatives, safe taken first", got)
509+
}
510+
}
511+
383512
// Two tokens writing one feature in one step is a choice point naming both
384513
// writes and the one that stood, whether or not the values differ: which
385514
// performance wrote last is the executor's order either way.

internal/core/runtime/state_change_trigger.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,9 @@ func (e *StateExecutor) pollChangeEvents() (bool, error) {
5858
return false, nil
5959
}
6060

61-
candidates, err := e.selectCandidates(func(state *ast.StateNode) (*lower.Transition, error) {
62-
return e.risenChangeTransition(state, poll)
61+
candidates, err := e.selectCandidates(func(state *ast.StateNode) (*lower.Transition, []RunNote, error) {
62+
trans, err := e.risenChangeTransition(state, poll)
63+
return trans, nil, err
6364
})
6465
if err != nil {
6566
e.changeWaits = poll.waits

0 commit comments

Comments
 (0)