eta_statem (eta v0.1.0)

Copy Markdown

gen_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

  1. Arming. eta_transform wraps every state callback so its return passes through return/3. A {state_timeout, Time, Content} action is taken out of the list and a virtual deadline armed in its place, so gen_statem never learns a time-out was asked for and never starts a timer of its own.
  2. Delivery. The deadline sends {'$eta_statem', Ref} to the machine, where it arrives as an ordinary info event. A clause eta_transform prepends 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 of function_clause — and returns {keep_state_and_data, [{next_event, state_timeout, Content}]}. gen_statem then delivers a genuine state_timeout event through its own machinery, so the callback sees exactly the event type and content it would have seen.
  3. Cancelling. return/3 compares the state the callback was called in with the state its return names, and cancels on a change. That is gen_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 =:=. So keep_state, keep_state_and_data, repeat_state, repeat_state_and_data and {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 = infinity cancels 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, which gen_statem documents 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_statem parks the ref and drops the message when it arrives, without invoking a callback. eta_time:disarm_self/2 flushes it from the mailbox instead, which reaches the same place: return/2 runs in the machine's process, between two of gen_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

timeout_type()

-type timeout_type() :: state_timeout.

Functions

armed()

-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.

fire(Ref)

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.

init_return(Ret)

-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.

return(EventType, State, Ret)

-spec return(term(), term(), Ret) -> Ret.

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.

tag()

-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.