eta_transform (eta v0.1.0)

Copy Markdown

A 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:

  1. Timer and clock rewriting — the table below, applied always.
  2. Message sending — points ! and the cast/reply functions at eta_net, so a simulated network can drop, delay or cut them. Applied unless the module declares -eta_net(false); see below.
  3. Receive timeouts — puts receive ... after T on the virtual clock. Applied unless the module declares -eta_after(false); see below.
  4. State observability — applied only to a module declaring -eta_observe(all) or -eta_observe({Record, Fields}), which republishes the state on every gen_server or gen_statem callback return so a simulation can read it while the process is suspended. See eta_observe.
  5. gen_statem state 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). See eta_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 end

into 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

FromTo
erlang:send_after/3,4eta_time:send_after/3,4
erlang:start_timer/3,4eta_time:start_timer/3,4
erlang:cancel_timer/1,2eta_time:cancel_timer/1,2
erlang:read_timer/1eta_time:read_timer/1
erlang:monotonic_time/0,1eta_time:monotonic_time/0,1
erlang:system_time/0,1eta_time:system_time/0,1
erlang:timestamp/0eta_time:timestamp/0
os:system_time/0,1eta_time:system_time/0,1
os:timestamp/0eta_time:timestamp/0
timer:sleep/1eta_time:sleep/1

Sending

The network pass rewrites, unless the module declares -eta_net(false):

FromTo
Dest ! Msgeta_net:send(Dest, Msg)
erlang:send/2,3eta_net:send/2,3
gen_server:cast/2eta_net:cast/2
gen_statem:cast/2eta_net:cast/2
gen_server:reply/2eta_net:reply/2
gen_statem:reply/1,2eta_net:reply/1,2
erlang:monitor/2,3eta_net:monitor/2,3
erlang:demonitor/1,2eta_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), every handle_call/3 clause is wrapped so a {reply, R, S} return becomes a routed eta_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 through eta_net:reply/2 and removed from the list. See eta_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_responseat run time, while a network is running
gen_statem's send_request/wait_response/receive_response/check_responsethe same
every client-side function of gen_eventthe 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

parse_transform(Forms, Options)

-spec parse_transform(Forms, list()) -> Forms when Forms :: [erl_parse:abstract_form()].

The parse_transform entry point. See the module doc.