eta_statem (eta v0.1.0)
Copy Markdowngen_statem's own time-outs, on the virtual clock.
A parse_transform reaches a module's calls. It does not reach gen_statem,
and a state time-out is armed by erlang:start_timer/4 inside gen_statem —
so a state machine under simulation held one real-clock dependence that no
amount of rewriting its own code could remove.
Worse than a slow run. The timer lands in the VM's wheel rather than
eta_time's, so strays/0 cannot see it and nothing reports it: the driver
finds nothing runnable, finds no deadline to advance to, and the run either ends
early or waits out the wall clock, with the interleaving from that point decided
by the machine rather than by the seed.
This module takes the time-out away from gen_statem instead, through the one
seam it does offer — the action list a callback returns, which is an
ordinary term the transform can read before OTP ever sees it.
Three pieces, and none of them works without the other two
- Arming.
eta_transformwraps every state callback so its return passes throughreturn/3. A{state_timeout, Time, Content}action is taken out of the list and a virtual deadline armed in its place, sogen_statemnever learns a time-out was asked for and never starts a timer of its own. - Delivery. The deadline sends
{'$eta_statem', Ref}to the machine, where it arrives as an ordinaryinfoevent. A clauseeta_transformprepends to every state callback catches it — before the module's own clauses, which is the whole point, since they would not match it and the machine would die offunction_clause— and returns{keep_state_and_data, [{next_event, state_timeout, Content}]}.gen_statemthen delivers a genuinestate_timeoutevent through its own machinery, so the callback sees exactly the event type and content it would have seen. - Cancelling.
return/3compares the state the callback was called in with the state its return names, and cancels on a change. That isgen_statem's rule, and getting it wrong in either direction is a bug you would not spot: cancel too eagerly and a heartbeat stops rearming, too lazily and a machine fires an election it already won.
Except for Time = 0, which stays gen_statem's
A zero time-out consults no clock. gen_statem registers it against a ref that
is not a timer ref and queues it, so there is nothing in it to virtualise, and it
is passed straight through.
That is not a shortcut, it is the only faithful option. A zero time-out is
delivered when the event queue drains and before the next message is taken from
the mailbox — after inserted and postponed events, and before anything new. It is
also still cancellable while it waits: loop_t0q/5 re-checks the timer table
before delivering, so an inserted or postponed event that changes state first
means it never arrives at all. "Do this next, unless I leave the state before I
get to it" is the whole reason the idiom exists, and neither substitution
available here reproduces it — {next_event, ...} goes to the head of the queue
and can no longer be cancelled, and a message to self goes behind whatever is
already in the mailbox.
The cost is that the single state time-out is sometimes gen_statem's and
sometimes this module's, so this module tracks which. When it is gen_statem's,
a cancel or a replacement is put back into the action list for gen_statem to
apply to itself rather than being applied here. Split ownership with nobody
tracking it was the first version, and it fires the time-out twice: the machine
arms a zero, an inserted event replaces it with a real one, and both go off.
Forging gen_statem's own timer message was tried first and cannot work. It
matches {timeout, TimerRef, TimeoutType} and then looks TimerRef up in a
timers map held in its private loop state, so a message from outside is never
recognised as a time-out and falls through to info.
The cancellation rules, exactly
Taken from gen_statem's implementation rather than from its documentation,
because two of them are not written down anywhere near each other:
- A state change cancels it. A change is
NextState =/= State, compared with=:=. Sokeep_state,keep_state_and_data,repeat_state,repeat_state_and_dataand{next_state, SameState, _}all leave it running. A machine that rearms on every heartbeat with{keep_state, D, [{state_timeout, T, election}]}depends on both halves of that: no cancel from the return, and a fresh deadline from the action. - Setting one cancels any running one. There is at most one per machine.
Time = infinitycancels and starts nothing, and{state_timeout, cancel}is the same thing said clearly.{state_timeout, update, Content}changes the content and leaves the clock alone — and if nothing is running it is not an error. It inserts the event immediately, whichgen_statemdocuments as "a time-out autostart with immediate expiry, so there will be noise for example if a generic time-out name was misspelled". Mirrored rather than improved on.- A cancelled time-out that had already fired produces no event at all.
gen_statemparks the ref and drops the message when it arrives, without invoking a callback.eta_time:disarm_self/2flushes it from the mailbox instead, which reaches the same place:return/2runs in the machine's process, between two ofgen_statem's receives, so the message is sitting in the mailbox where a selective receive can take it.
What is not owned here, and what happens if you ask
Event time-outs ({timeout, Time, Content}, or a bare integer action) and
generic time-outs ({{timeout, Name}, Time, Content}) are not virtualised,
and raise while a clock is running rather than quietly staying on the wall
clock. Same position eta_net takes on the messaging it cannot route: the
boundary is discoverable by using it rather than by reading a table.
They are not merely unimplemented, they are unsafe to leave alone here. Every
event gen_statem processes cancels a running event time-out, and delivery
above spends one — the info event carrying {'$eta_statem', Ref}. So a
machine holding both would find its event time-out cancelled by this module's
own traffic. Raising is the honest answer until they are owned too, and the
shape of this module is meant to take them: the arming table is keyed by
time-out type, and {timeout, Name} is a key like any other.
hibernate is untouched and unsupported for the same reasons as before.
Inert unless a clock is running
return/2 hands the term straight back when there is none, so every action goes
to gen_statem and behaves exactly as it always did. A module built with the
transform is therefore unchanged outside a simulation — including in the
ordinary test suite that runs alongside one.
The one hazard is the same one timers have always had here: a state time-out
armed before the clock started belongs to gen_statem, and this module will
not know about it. Start the clock before the system, which is what eta_run
does.
This documentation is LLM-generated. See the AI disclosure in README.md.
Summary
Functions
The time-outs this process currently has armed, as #{Type => Content}.
Turns a delivered deadline into the event gen_statem would have produced.
The same for init/1's return, which can carry actions and therefore a state
time-out.
What a state callback's return means for the time-outs this module owns, given the state the callback was called in.
The atom heading the message a virtual deadline delivers.
Types
Functions
-spec armed() -> #{timeout_type() => term()}.
The time-outs this process currently has armed, as #{Type => Content}.
For a test that wants to state "the election deadline is still running" without waiting to find out. Empty when none are, which is also what a process with no state machine in it reports.
-spec fire(reference()) -> gen_statem:event_handler_result(term()).
Turns a delivered deadline into the event gen_statem would have produced.
Called from the clause eta_transform prepends to every state callback. Returns
{keep_state_and_data, [{next_event, state_timeout, Content}]} for a deadline
that is still armed, and plain keep_state_and_data for one that is not.
The second case should be unreachable — cancelling flushes the message — and is answered rather than raised on, because the cost of being wrong is a state machine that dies on a message it was never meant to see.
The entry is taken, not read. When a real time-out fires gen_statem removes
it from its timer table before delivering the event, so a callback handling a
state time-out has none running; {state_timeout, update, _} in that callback
autostarts rather than updating, and this has to agree.
-spec init_return(Ret) -> Ret.
The same for init/1's return, which can carry actions and therefore a state
time-out.
Separate from return/2 because there is no state to have changed — the machine
is entering its first one — so there is nothing to cancel and no previous state
to compare against. eta_sched:statem_start_link/3 hands init/1's actions
straight to gen_statem:enter_loop/6, so a time-out set here would otherwise be
armed by OTP on the real clock before the machine had processed a single event.
What a state callback's return means for the time-outs this module owns, given the state the callback was called in.
eta_transform wraps every state callback with this. Two jobs, in gen_statem's
own order: a state change cancels the state time-out, and then the action list
is read for new ones. Returns the value with the actions it consumed removed.
State is the state the callback ran in — the third argument in
handle_event_function mode, and the function's own name in state_functions
mode, where the transform supplies it as a literal.
-spec tag() -> atom().
The atom heading the message a virtual deadline delivers.
Read by eta_transform at compile time to build the clause it prepends, so
the pattern that catches the message and the code that sends it cannot drift
apart. Same trick, and the same reason, as eta_observe:key/0.