Skip to content

fix(bots): keep a bot script's Lua state across think calls, and bound it - #571

Merged
Taure merged 3 commits into
mainfrom
fix/bot-lua-state-persistence
Aug 29, 2026
Merged

Taure merged 3 commits into
mainfrom
fix/bot-lua-state-persistence

Conversation

@Taure

@Taure Taure commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

What was wrong

asobi_bot called the operator's Lua think(bot_id, state) and threw away the Luerl state it got back, so every tick re-entered the script exactly as it was loaded. A bot script could not remember anything between calls.

That made guides/lua-bots.md wrong where it told authors to key per-bot state off bot_id in a module-level table, and it is what a forum poster hit trying to make a bot wait four seconds between commands. Their client-side guard fired every tick because the value it compared against had already reset. Working around it server-side was the only thing that could have worked.

Reproduced before fixing, and the regression tests fail on main with the exact symptom:

globals_persist:      expected [1,2,3]  got [1,1,1]
cooldown_gates_input: expected [1,4,7]  got [1,1,1]

What changed

The state is threaded across ticks (b1cef15), so a bot script's globals persist the way a match script's do. On failure the pre-call state is carried forward, so a bot that fails every tick cannot grow the state it is failing on; a think that returns no values keeps its state and only the input falls back.

And then bounded (2971a17). Persisting removed the only limit a bot's Lua memory had: luerl:gc/1 reclaims garbage, not what a script keeps live, and its mark phase is quadratic in the live set, so a hoarding script drives the adaptive collector past its abandon threshold and it switches off for that state. Measured 139 MB per bot after 400 ticks, with a mean tick of 104 ms against a 100 ms loop. collect_state/1 now returns the size it was already measuring, and a bot past max_bot_state_words (10 MB, configurable) reloads its script. A bot is the one Lua bridge where that is affordable: nothing a player can see lives in its state.

Also hardened on the same path:

  • decode_result/2 gains a try/catch. luerl:decode/2 raises on a self-referencing table, and an uncaught raise kills a temporary child, so the match lost the bot for the rest of its life.
  • format_status/1, so a crash report does not format a multi-MB state (measured 10 MB of log, 1.4 s).
  • The think-error log bounds the reason it echoes, since a script chooses what it raises with. Its throttle moves out of persistent_term, whose keys were per bot id and therefore never reused.
  • An input reaching the match script drops funs, pids, ports and refs. Nested tables are kept, since a bot may send the shaped input a player can.

Docs

guides/lua-bots.md gains Timing and cooldowns (count your own think calls, or count a tick counter the match broadcasts) and What a bot may keep. It also now says that think runs on a fixed 100 ms loop independent of tick_rate, that a bare return runs the default AI rather than declining, and that a failed call is rolled back whole so a deterministic failure repeats forever.

ADR 0005, asobi_telemetry and guides/observability.md pick up kind => bot, with bot_id explicitly not a label. The trust model notes that the state copy sits outside the callback budget.

Review

Ran the full gate. The architecture guardian accepted the design and independently reproduced the bug; the security reviewer raised the memory ceiling as a merge blocker, which is what 2971a17 addresses; the code reviewer found the {ok, [], _} clause untested.

Six regression tests, each verified to fail when its own fix is neutralised.

fmt / xref / dialyzer / eqwalize clean, full eunit 2406 passing, asobi_lua_SUITE 8/8. DB-backed CT suites were not run locally: Docker is not reachable from this machine.

Behaviour changes for anyone already running bots

  • A bot script's globals now persist between think calls.
  • A script holding more than 10 MB of Lua state is reloaded and its globals reset.
  • The think-error log carries its message under event rather than msg. The string is unchanged, so a grep still matches.

Taure added 2 commits August 29, 2026 16:21
asobi_bot called think/2 and threw away the Luerl state it got back, so
every tick re-entered the script exactly as it was loaded. A bot script
could not remember anything between calls: a per-bot cooldown table reset
every 100 ms and the guard built on it fired on every tick, which is what
a forum poster hit trying to make a bot wait four seconds between
commands. The guide told them to use that exact pattern.

Thread the state through send_input/1 instead. Persisting it means a bot
now pays the per-tick copy a match bridge pays, so run
asobi_lua_loader:collect_state/1 before each call on the same adaptive
schedule. The bot holds no Luerl reference across ticks - game_state is
the Erlang term the match broadcast, re-encoded every tick - so the
collector gets no anchor.

A think that returns no values keeps its state; only the input falls back
to the default AI. A think that errors, times out or overruns its heap
keeps the pre-call state, as every other bounded caller does.

Guide gains a "Timing and cooldowns" section covering both clocks: count
your own calls, or count a tick counter the match broadcasts, with the
note that think runs on a fixed 100 ms loop independent of tick_rate.
ADR 0005 gains kind => bot on [asobi, lua, state].
Follow-up to b1cef15, applying the gate's findings.

Persisting the Luerl state removed the only bound a bot's Lua memory had.
luerl:gc/1 reclaims garbage, not what a script keeps live, and its mark
phase is quadratic in the live set, so a hoarding script drives the
adaptive collector past ?GC_ABANDON_US and it switches itself off for
that state: measured 139 MB per bot after 400 ticks and a mean tick of
104 ms against a 100 ms loop. collect_state/1 now returns the size it
already measured, and a bot past max_bot_state_words (10 MB, overridable
under {asobi, [...]}) reloads its script. A bot is the one Lua bridge
where that is affordable - nothing a player can see lives in its state.

decode_result/2 gains a try/catch: luerl:decode/2 raises on a table that
references itself, and an uncaught raise kills a `temporary` child, so
the match lost the bot for the rest of its life. The gen_server also
gains format_status/1 - a crash report formatting a multi-MB state
measured 10 MB of log and 1.4 s.

The think-error log bounds the reason it echoes, since a script chooses
what it raises with and can raise with megabytes. Its throttle moves from
persistent_term into the bot's own state: bot ids carry a random
discriminator and are never reused, so the old key was a table that only
grew, and every write scanned the literal area across all processes.

An input reaching the match script drops funs, pids, ports and refs.
Nested tables are kept - a bot may send the shaped input a player can.

send_input/1 splits into run_think/4 and collect_lua_state/1, and the
bridge metadata is stamped once in init/1 the way asobi_lua_match and
asobi_lua_world do it, so collect_lua_state/1 is maps:without/2 on the
anchor rather than a hand-built map.

Tests cover the four paths the gate found untested, each verified to fail
when its own fix is neutralised: a bare `return` (zero values, distinct
from `return {}`) keeping state, the ceiling reload, a self-referencing
result, and a returned function.

Docs: the bots guide gains "What a bot may keep" and now says a bare
`return` runs the default AI rather than declining, and that a failed
call is rolled back whole so a deterministic failure repeats forever.
ADR 0005 no longer claims eight bots are eight series - they collapse on
script/kind, and bot_id must never be a label. asobi_telemetry and
guides/observability.md catch up with the enum. The trust model notes
that the state copy sits outside the callback budget.

Behaviour changes for anyone already running bots: a bot script's globals
now persist (b1cef15), a script holding more than 10 MB is reloaded, and
the think-error log carries the message under `event` rather than `msg`.
@github-actions

github-actions Bot commented Aug 29, 2026 •

Copy link
Copy Markdown

🟡 Code Coverage — 76.9%

9423 of 12258 lines covered.


🟡 ELP Lint — 192 warnings

212 diagnostics found. See job logs for details.


✅ ELP eqWAlize

No diagnostics.

Two CI failures, both in the module this branch touches.

Dialyzer: run_think/4's catch-all clause is unreachable now that the
three shapes asobi_lua_loader:call/4 can return are all matched.

eqWAlize: handle_presence_message/2 was spec'd on
asobi_presence:message/0 while its only caller passes what arrived in the
mailbox, which is term(). That error predates this branch, but the CI
gate is scoped to modules a PR touches, so it surfaces here. Spec the
consumer as dynamic() - a mailbox is a boundary and the clause heads are
what decide the shape - and leave the contract enforced where it always
was, on the producing asobi_presence:send/2 call.
@Taure Taure added the gates-passed Architecture, security and code review have run and the owner agreed label Aug 29, 2026
@Taure
Taure merged commit e1c437e into main Aug 29, 2026
18 checks passed
@Taure
Taure deleted the fix/bot-lua-state-persistence branch August 29, 2026 20:02
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