Skip to content

Commit b1fc2e2

Browse files
fgmacedochiliec
andauthored
fix: honor Event(internal=True) and give internal events from outside a step the external contract (#668)
--------- Signed-off-by: Fernando Macedo <fgmacedo@gmail.com> Co-authored-by: Vladimir Babin <vovababin@gmail.com>
1 parent 0de640a commit b1fc2e2

17 files changed

Lines changed: 800 additions & 126 deletions

‎AGENTS.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,14 @@ The engine follows the SCXML run-to-completion (RTC) model with two processing l
3939
### Event queues
4040

4141
- `send()` → **external queue** (processed after current macrostep ends).
42-
- `raise_()` → **internal queue** (processed within the current macrostep, before external events).
42+
- `raise_()`, `send(internal=True)`, `Event(internal=True)` → **internal queue** (processed
43+
within the current macrostep, before external events), but only when raised while the
44+
machine runs a step in the same thread or task (`_running_engine` in `engines/base.py`, a
45+
`ContextVar` so it is right for threads and coroutines alike). From outside a step there
46+
is no macrostep to join: the trigger goes to the external queue, so its caller gets the
47+
result, the exception or `TransitionNotAllowed`.
48+
- `delay` and `internal` together raise `InvalidDefinition`: a step takes no time.
49+
- Each external event is followed by its macrostep before the next external event is taken.
4350

4451
### Error handling (`catch_errors_as_events`)
4552

‎docs/async.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,11 @@ resolves each event's future as it processes the queue. Callers that didn't
155155
acquire the lock simply `await` their future.
156156

157157
```{note}
158-
Futures are only created for **external** events sent from outside the
159-
processing loop. Events triggered from within callbacks (via `send()` or
160-
`raise_()`) follow the {ref}`run-to-completion <rtc-model>` model — they
161-
are enqueued and processed within the current macrostep.
158+
Futures are created for every event sent from outside the processing loop,
159+
including one declared `internal=True`, which goes to the external queue
160+
when there is no macrostep to join. Events triggered from within callbacks
161+
(via `send()` or `raise_()`) follow the {ref}`run-to-completion <rtc-model>`
162+
model: they are enqueued and processed by the running loop.
162163
```
163164

164165
If an exception occurs during processing (with `catch_errors_as_events=False`),

‎docs/processing_model.md‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,51 @@ After the macrostep completes, the engine picks the next event from the
7676

7777
### Event queues
7878

79-
The engine maintains two separate FIFO queues:
79+
The engine maintains two separate queues, each ordered by due time. Events due at the same
80+
moment run in the order they were sent.
8081

8182
| Queue | How to enqueue | When processed |
8283
|---|---|---|
83-
| **Internal** | {func}`raise_() <StateMachine.raise_>` or `send(..., internal=True)` | Within the current macrostep |
84-
| **External** | {func}`send() <StateMachine.send>` | After the current macrostep ends |
84+
| **Internal** | {func}`raise_() <StateMachine.raise_>`, `send(..., internal=True)`, or an event declared with `Event(..., internal=True)`, from a callback | Within the current macrostep |
85+
| **External** | {func}`send() <StateMachine.send>`, and any event triggered from outside a callback | After the current macrostep ends |
8586

8687
This distinction matters when you trigger events from inside callbacks.
8788
Using `raise_()` ensures the event is handled as part of the current
8889
processing cycle, while `send()` defers it to after the machine reaches
89-
a stable configuration.
90+
a stable configuration. An event declared `internal=True` behaves like
91+
`raise_()` without spelling it at each call:
92+
93+
```py
94+
>>> from statemachine import Event, State, StateChart
95+
96+
>>> class Muster(StateChart):
97+
... camp = State(initial=True)
98+
... riding = State(final=True)
99+
... besieged = State(final=True)
100+
...
101+
... summon = camp.to.itself(internal=True, on="call")
102+
... ride = Event(camp.to(riding), internal=True)
103+
... siege = camp.to(besieged)
104+
...
105+
... def call(self):
106+
... self.send("siege") # after this macrostep
107+
... self.ride() # within this macrostep, so first
108+
109+
>>> sm = Muster()
110+
>>> sm.send("summon")
111+
>>> sm.riding.is_active
112+
True
113+
114+
```
115+
116+
The internal queue belongs to the macrostep in progress. Triggered from
117+
outside a callback, there is no macrostep to join: an internal event goes
118+
to the external queue, and the call returns its result like `send()`.
119+
120+
A delay cannot be internal, since a macrostep takes no time. Declaring or
121+
sending an event with both `delay` and `internal=True`, or calling
122+
`raise_()` with a `delay`, raises {class}`~statemachine.exceptions.InvalidDefinition`.
123+
Use `send(..., delay=...)` to deliver an event later.
90124

91125
```{seealso}
92126
See {ref}`sending-events` for examples of `send()` vs `raise_()`.

‎docs/releases/3.2.2.md‎

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,7 @@ Delays are now measured on `time.monotonic()`, which no setting of the wall cloc
117117

118118
`Event(dark.to(lit), delay=50)` rebuilt the event without its `delay`, so
119119
`BeaconsOfGondor.light.delay` was `0` and the event fired immediately instead of being queued.
120-
The same happened to `internal`. Both are now preserved, although `internal` still has no
121-
effect at trigger time:
120+
The same happened to `internal`. Each is now preserved:
122121

123122
```py
124123
>>> class BeaconsOfGondor(StateChart):
@@ -132,6 +131,62 @@ effect at trigger time:
132131

133132
```
134133

134+
### `internal` declared on an `Event` ignored at trigger time
135+
136+
`go = Event(a.to(b), internal=True)` kept `internal` on the declaration, but neither `sm.go()`
137+
nor `sm.send("go")` carried it to the queue, so the event always went to the external queue. Both
138+
now honor the declaration: triggered from a callback, the event runs within the current
139+
macrostep, before any pending external event.
140+
141+
Honoring it exposed how the internal queue treated a trigger that no running step had raised.
142+
On the async engine, the first `await sm.go()` on a fresh machine was lost before the initial
143+
state was entered. The caller got `None` instead of the callback result, and no
144+
`TransitionNotAllowed` on a `StateMachine`. The internal queue now receives only what the
145+
machine raises while it runs a step. Triggered from outside, an internal event goes to the
146+
external queue and returns its result, as `send()` does:
147+
148+
```py
149+
>>> class Beacons(StateChart):
150+
... dark = State(initial=True)
151+
... lit = State(final=True)
152+
...
153+
... light = Event(dark.to(lit, on="kindle"), internal=True)
154+
...
155+
... def kindle(self):
156+
... return "kindled"
157+
158+
>>> Beacons().light()
159+
'kindled'
160+
161+
```
162+
163+
A delay never reached the internal queue either: `raise_("go", delay=50)` fired at once. A
164+
macrostep takes no time, and SCXML forbids `delay` on a `<send>` to `#_internal`, so a delayed
165+
internal event is now an invalid definition, whether declared, sent, raised, or loaded
166+
from an SCXML, JSON or YAML `<send>` to `#_internal` (also rejected by the JSON Schema):
167+
168+
```py
169+
>>> class DelayedBeacons(StateChart):
170+
... dark = State(initial=True)
171+
... lit = State(final=True)
172+
...
173+
... light = Event(dark.to(lit), delay=50, internal=True)
174+
Traceback (most recent call last):
175+
...
176+
InvalidDefinition: Event 'light' cannot be both delayed and internal: ...
177+
178+
```
179+
180+
Drop `internal` to deliver the event later on the external queue, or drop `delay` to run it
181+
within the current macrostep. An explicit `send(..., internal=False)` overrides the declaration, so
182+
`send("light", delay=50, internal=False)` delivers a declared-internal event later.
183+
184+
Contributed by [@chiliec](https://github.com/chiliec).
185+
186+
[#655](https://github.com/fgmacedo/python-statemachine/issues/655),
187+
[#663](https://github.com/fgmacedo/python-statemachine/pull/663),
188+
[#668](https://github.com/fgmacedo/python-statemachine/pull/668).
189+
135190
### `is_active` on a state nested in a parallel region
136191

137192
Reaching a child state through its parent (`sm.door.shut`) returned the class-level `State`,
@@ -167,3 +222,38 @@ Reported by [@MartinBernstorff](https://github.com/MartinBernstorff).
167222

168223
[#651](https://github.com/fgmacedo/python-statemachine/issues/651),
169224
[#652](https://github.com/fgmacedo/python-statemachine/pull/652).
225+
226+
### Each external event gets its own macrostep
227+
228+
With several external events already queued, the engine took them one after the other and
229+
only then ran the internal events and eventless transitions they had enabled. An internal
230+
event raised while handling the first external event ran after the second, breaking the
231+
run-to-completion order the {ref}`processing model <macrostep-microstep>` describes:
232+
233+
```py
234+
>>> class Beacons(StateChart):
235+
... dark = State(initial=True)
236+
... lit = State(final=True)
237+
... besieged = State(final=True)
238+
...
239+
... signal = dark.to.itself(internal=True, on="relay")
240+
... light = dark.to(lit)
241+
... siege = dark.to(besieged)
242+
...
243+
... def relay(self):
244+
... self.raise_("light")
245+
... self.send("siege")
246+
247+
>>> sm = Beacons()
248+
>>> sm.send("signal")
249+
>>> sm.lit.is_active
250+
True
251+
252+
```
253+
254+
Each external event is now followed by its macrostep before the next one is taken, on both
255+
engines. Eventless transitions are now checked after every external event instead of once per
256+
batch, so a burst of queued external events costs one guard evaluation each, and an event
257+
queued behind one that reaches a final state is no longer processed.
258+
259+
[#668](https://github.com/fgmacedo/python-statemachine/pull/668).

‎docs/statechart.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,24 @@ True
8888

8989
```
9090

91+
Called from outside a callback, `raise_()` has no macrostep to join, so it
92+
behaves like `send()` and returns the result:
93+
94+
```py
95+
>>> class Greeter(StateChart):
96+
... idle = State(initial=True)
97+
... done = State(final=True)
98+
...
99+
... greet = idle.to(done, on="say_hello")
100+
...
101+
... def say_hello(self):
102+
... return "hello"
103+
104+
>>> Greeter().raise_("greet")
105+
'hello'
106+
107+
```
108+
91109
Both methods accept arbitrary keyword arguments that are forwarded to all
92110
callbacks via {ref}`dependency injection <dependency-injection>`.
93111

@@ -115,6 +133,10 @@ sm.cancel_event("my_timeout")
115133

116134
On the async engine, a coroutine awaiting the `send()` of a cancelled event gets `None`.
117135

136+
Only `send()` takes a delay. An internal event runs within the current macrostep,
137+
which takes no time, so `raise_(..., delay=...)` and `send(..., delay=..., internal=True)`
138+
raise {class}`~statemachine.exceptions.InvalidDefinition`.
139+
118140
```{note}
119141
The delay is **blocking** in the sync engine — the processing loop sleeps until the
120142
delay elapses, holding the calling thread. In the async engine, delays are scheduled

0 commit comments

Comments
 (0)