Skip to content

docs(adr): propose the Luerl VM owning the state (ADR 0015) - #541

Merged
Taure merged 1 commit into
mainfrom
docs/adr-0015-luerl-vm
Aug 21, 2026
Merged

Taure merged 1 commit into
mainfrom
docs/adr-0015-luerl-vm

Conversation

@Taure

@Taure Taure commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Proposes ADR 0015. Nothing is implemented and this PR changes no code - it
is one file under docs/adr/, in Proposed status, so the decision can be
argued in one place before anyone writes the refactor.

Follows #538 (asobi#536, v0.94.1). That change made the per-eval heap budget
mean what it says and made the Luerl state size observable. It did not remove
the per-callback copy, and this ADR is about the copy.

The number the ADR turns on

Every bounded Lua callback runs in a process spawned per call, and the spawn
copies the whole persistent Luerl state into it. For a callback that does
nothing at all
:

state call/4 (worker + copy) call/3 (inline)
0.4 MB 1.81 ms 0.003 ms
6 MB 41.19 ms 0.005 ms
24 MB 101.63 ms 0.006 ms
62 MB 418.19 ms 0.004 ms

Roughly 7 ms per MB. At the 400-690 MB #536 reported, that is three to five
seconds per callback before the script runs a line - which is the "the world
is empty" symptom on its own, with or without a heap kill.

The spike holds the state in a process the bridge talks to instead, so the
bridge passes small messages and opaque refs. Re-measured against this branch's
base:

state copying owned VM speedup
0 MB 0.76 ms 0.117 ms 7x
3 MB 5.29 ms 0.088 ms 60x
12 MB 24.14 ms 0.927 ms 26x
37 MB 67.33 ms 0.896 ms 75x
75 MB 205.70 ms 0.383 ms 537x

Not faster in kind - flat. O(work) where the current path is O(state). It
wins at a state of no size too, because spawn-copy-monitor costs more than a
gen_server round trip.

What it would cost

A runaway callback would cost the bridge's Lua state rather than one tick,
because the only killable thing becomes the process holding it. Zones can
absorb that - dump_zone_state/1 already decodes game_state to a plain map
and asobi_zone:maybe_restore_from_snapshot/1 already rebuilds from the DB
snapshot on boot - but matches cannot, since asobi_match_server keeps
game_state in the FSM and never persists it. That asymmetry is the part
wanting a ruling.

Two things fall out for free if it is accepted: the per-zone ETS template table
exists only because a callback's self() is an ephemeral worker, and
handle_input's sandbox exemption exists only because a copy per input frame
was unaffordable. Both would be retirable. The game.zone.spawn deadlock
hazard would not go away.

Migration surface, counted on main: 51 direct luerl:* call sites outside the
loader, 97 references to lua_state threaded through bridge state maps, 31
asobi_lua_loader:call sites.

Rejected alternatives, recorded

Leaving it as a documented property; running callbacks inline and unguarded;
capping state size and refusing callbacks past it; bounding the eval with a
Luerl trace hook (it stores a fun in the state and fires per statement);
snapshotting before each callback (the snapshot is the copy).

The spike

spike/lua-vm-process carries the working asobi_lua_vm_spike gen_server and
the bench that produced the second table. It is deliberately not part of this
PR
: the bench asserts nothing and would add ~8 seconds to every CI eunit run.
Check it out to re-run the numbers:

git checkout spike/lua-vm-process
rebar3 as test eunit --module=asobi_lua_vm_spike_bench

What accepting this does and does not commit to

Accepting means the direction is agreed and the refactor can be scheduled - not
that it lands next. Rejecting means the copy stays a documented cost, #538's
telemetry is how you watch it, and #540's ceiling is how you survive it.

Records the decision the asobi#536 spike exists to inform: stop copying the
persistent Luerl state into a per-callback worker, and hold it in a process the
bridge talks to instead.

Proposed, not accepted, and nothing is implemented. Carries both measurement
tables, the migration surface counted on main, and the price - a runaway
callback would cost the bridge's Lua state rather than one tick, which zones
can absorb through asobi_zone_snapshotter and matches cannot. Five rejected
alternatives, including the Luerl trace-hook deadline and snapshot-before-call.
@Taure Taure added the gates-passed Architecture, security and code review have run and the owner agreed label Aug 21, 2026
@Taure
Taure merged commit 0297c96 into main Aug 21, 2026
@Taure
Taure deleted the docs/adr-0015-luerl-vm branch August 21, 2026 11:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gates-passed Architecture, security and code review have run and the owner agreed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant