eta_transform (eta v0.1.0)
Copy MarkdownA parse_transform that points a module's timer and clock calls at eta_time —
Phase 1 of the DST framework (design: docs/design.md).
Nothing else about the module changes, and the rewrite is a pure call-target
substitution: erlang:send_after(T, D, M) becomes eta_time:send_after(T, D, M),
and so on for the table below.
Five passes, one transform
A module never names more than one parse_transform. The extra passes are
controlled by attributes rather than by naming another transform:
- Timer and clock rewriting — the table below, applied always.
- Message sending — points
!and thecast/replyfunctions ateta_net, so a simulated network can drop, delay or cut them. Applied unless the module declares-eta_net(false); see below. - Receive timeouts — puts
receive ... after Ton the virtual clock. Applied unless the module declares-eta_after(false); see below. - State observability — applied only to a module declaring
-eta_observe(all)or-eta_observe({Record, Fields}), which republishes the state on everygen_serverorgen_statemcallback return so a simulation can read it while the process is suspended. Seeeta_observe. gen_statemstate time-outs — takes{state_timeout, ...}out of a callback's returned action list and arms a virtual deadline instead, and prepends the clause that delivers it. Applied to every module declaring-behaviour(gen_statem). Seeeta_statem.
They live here rather than in modules of their own for a build reason, and it is
worth recording because it cost a broken CI run to find. Mix does not reliably
order a module after more than one parse_transform. With two attributes on
one module, an incremental build where both the transform and that module
changed failed with undefined parse transform roughly two times in three —
nondeterministically, so it looked intermittent before it was pinned down. One
attribute is ordered correctly; two are not.
Delegating from here to a separate module does not reliably help either. Mix must compile a transform before anything that uses it and does not track a runtime call as a dependency, so within this project the hazard just moves down a level. Merging is what removes it.
So: never give a module two parse transforms. If a module needs another pass, add it here and gate it on an attribute.
Virtualising receive ... after
On by default. The rewrite turns
receive Pat -> Body after T -> Timeout endinto a receive whose timeout is an ordinary message clause, armed through
eta_time:arm_after/2. Under eta_sched the waiting process is correctly
unrunnable until the virtual clock delivers it, so a 60-second timeout costs
microseconds instead of a minute — and, more importantly, fires at a point the
schedule chose.
after 0 is left alone. It is a mailbox poll rather than a wait, never blocks,
and never consults a clock; routing it through the timer wheel would turn a
non-blocking poll into a blocking one.
Why it is on by default. after is the one real-time dependence a system
can hold without naming a function, so leaving it to an attribute means a module
compiled with this transform can still wake on the wall clock with nothing
reporting it. The call-rewriting pass is unconditional for the same reason;
there is no principled line that puts erlang:send_after/3 on one side of it
and after on the other.
-eta_after(false). opts out, for a module where the cost is measurable. It
is a ref, an update_counter and two inserts to arm, and a take to cancel.
No mailbox scan: eta_time:disarm_after/2 skips its flush when the cancel
succeeded, because a timer removed before it fired never sent anything. Worth
knowing about before putting it on a hot receive loop, and not worth thinking
about otherwise.
Why the timeout being an ordinary clause does not change the semantics
Worth stating, because it looks as though it should. Native after is a
fallback: it fires only when no queued message matches. The rewrite makes the
timeout an ordinary clause, and a receive takes the first message matching
any clause — so it appears to compete on arrival order rather than deferring.
It does not diverge, because arrival order and time order are the same order.
The {'$eta_after', Ref} message is appended at the deadline, so any message
that would have satisfied native after before it elapsed is necessarily older
in the mailbox and is scanned first. A message arriving after the deadline sits
behind a timeout that native would already have fired. Stale timeouts from an
earlier receive cannot match, because of the ref guard, and disarm_after/2
flushes one that fired just as a real message landed.
A two-stage rewrite — poll with after 0 first, arm only on a miss — would make
that structural rather than emergent, and would skip arming when a message is
already waiting. It is deliberately not done. The poll still walks the whole
mailbox before failing, so a miss scans twice, and a miss is the deep selective
receive gen_server:call/3 produces. It also duplicates every clause body into
both receives. With the flush gone from the hit path there is little left to
win.
Enabling it
Include the header, which carries the attribute behind the DST define:
-include_lib("eta/include/eta.hrl").and compile the simulation build with {d, 'DST'}. Per-module and opt-in, so
there is no way to ship it by accident and no runtime cost when it is off. The
same header defines ?ETA_LOG and ?ETA_LABEL; see eta_log.
Even when it is on, eta_time delegates to erlang unless a virtual clock is
running — so a module built with the transform behaves normally outside a
simulation.
What is rewritten
| From | To |
|---|---|
erlang:send_after/3,4 | eta_time:send_after/3,4 |
erlang:start_timer/3,4 | eta_time:start_timer/3,4 |
erlang:cancel_timer/1,2 | eta_time:cancel_timer/1,2 |
erlang:read_timer/1 | eta_time:read_timer/1 |
erlang:monotonic_time/0,1 | eta_time:monotonic_time/0,1 |
erlang:system_time/0,1 | eta_time:system_time/0,1 |
erlang:timestamp/0 | eta_time:timestamp/0 |
os:system_time/0,1 | eta_time:system_time/0,1 |
os:timestamp/0 | eta_time:timestamp/0 |
timer:sleep/1 | eta_time:sleep/1 |
Sending
The network pass rewrites, unless the module declares -eta_net(false):
| From | To |
|---|---|
Dest ! Msg | eta_net:send(Dest, Msg) |
erlang:send/2,3 | eta_net:send/2,3 |
gen_server:cast/2 | eta_net:cast/2 |
gen_statem:cast/2 | eta_net:cast/2 |
gen_server:reply/2 | eta_net:reply/2 |
gen_statem:reply/1,2 | eta_net:reply/1,2 |
erlang:monitor/2,3 | eta_net:monitor/2,3 |
erlang:demonitor/1,2 | eta_net:demonitor/1,2 |
! is the one rewrite that is not a call.
Why monitors are in this pass. A link failure fires every monitor held across
it, and a monitor is made by a BIF inside the system under test — so a simulated
network can only know one exists by being what creates it. Without the rewrite a
partition is invisible to the code that detects peers, which is most of the code
that matters. eta_net:monitor/2 simulates only a monitor whose ends are placed
on different simulated nodes and leaves every other one a plain erlang:monitor,
so the rewrite changes nothing for a module that is not part of a topology.
The monitor BIFs are auto-imported, so a bare monitor(process, Pid) is rewritten
too — on the same terms as the bare spawns below: only when the module does not
define that name itself. eta_net:send/2 returns Msg, which
is what ! evaluates to, so the substitution is value-preserving.
eta_net delegates to erlang:send/2 unless a network is running, so this pass
changes nothing about a run that does not start one. What it does change is who
can be faulted later: routing has to be uniform per channel, because a direct
send can overtake a delayed one, and module-granular rewriting is what makes that
safe. See eta_net.
gen_server:call/2,3 and gen_statem:call/2,3 are both rewritten to
eta_net:call/2,3, which routes the request. The reply leg is reached from
the other end, and how depends on the behaviour:
- In a module declaring
-behaviour(gen_server), everyhandle_call/3clause is wrapped so a{reply, R, S}return becomes a routedeta_net:reply(From, R)and a{noreply, S}. - In a module declaring
-behaviour(gen_statem), every state callback is wrapped so a{reply, From, R}action in the returned action list is sent througheta_net:reply/2and removed from the list. Seeeta_net:statem_return/1.
Both legs are then ordinary network traffic and can be faulted independently —
which is what makes "the work happened, the caller never learned it did" a
reachable state. See eta_net:call/3.
gen_statem's starts are gated like gen_server's, through
eta_sched:statem_start_link/3 and friends.
State time-outs are virtualised, by the fifth pass and eta_statem. That is
a gen_statem-shaped hole the other passes cannot reach, because the timer is
armed inside gen_statem rather than by anything this transform rewrites, and
the answer is to take the action out of the callback's return before OTP sees it.
What gen_statem support still does not include. The asynchronous
send_request/wait_response interface; hibernate; and the other two kinds
of time-out — event time-outs and generic ({timeout, Name}) ones — which raise
while a clock is running rather than staying quietly on the real one. See
eta_statem for why they cannot simply be left alone. init/1 returning
something other than {ok, State, Data} or {ok, State, Data, Actions} is
refused by eta_sched:statem_start_link/3 rather than guessed at.
What raises rather than being rewritten
Everything eta_net cannot yet put on the network raises, so the boundary is
discoverable by using it instead of by reading a table.
| when | |
|---|---|
gen_server:abcast, multi_call, send_request/wait_response/receive_response/check_response | at run time, while a network is running |
gen_statem's send_request/wait_response/receive_response/check_response | the same |
every client-side function of gen_event | the same |
a module declaring -behaviour(gen_event) | at compile time |
The last one is a property of the module rather than of a code path: a handler's
reply is produced inside the event manager, which is neither the module this
transform rewrote nor a process it can reach — so every call answered by such a
module would come around the network, in every run. -eta_net(false). opts the
module out of this pass and keeps the rest.
The runtime ones are runtime because a module may hold one on a path no simulation reaches, and refusing to compile it would block adoption over code the run never executes. Both are inert without a network.
Only qualified calls are rewritten
erlang:monotonic_time() is rewritten; a bare monotonic_time() is not. None of
these are auto-imported, so an unqualified call is a call to a function the module
defines itself — rewriting it would break the module. Code that wants to be
simulated must therefore qualify its time calls, which is the prevailing style
anyway.
timer:sleep/1 is rewritten, to eta_time:sleep/1. It went unrewritten for
a while on the argument that code under simulation should not sleep and a hang
would surface the smell — but the hang is not reliably loud (a sleeper is simply
never runnable, so a run can end at what looks like quiescence), and a sleep is
receive after T -> ok end one module away, the exact dependence the after
pass exists to virtualise. A sleep in an untransformed module still hangs.
This documentation is LLM-generated. See the AI disclosure in README.md.
Summary
Functions
The parse_transform entry point. See the module doc.
Functions
-spec parse_transform(Forms, list()) -> Forms when Forms :: [erl_parse:abstract_form()].
The parse_transform entry point. See the module doc.