Script Budgets, Errors, and Recovery
Memory and instruction limits, tracebacks, and failure handling keep useful scripts from overrunning their host.
Cheatsheet for Lesson 11.7
A one-page summary of “Script Budgets, Errors, and Recovery” you can keep beside your notes. Plain Markdown.
Lesson 11.7 — Script Budgets, Errors, and Recovery: cheatsheet
- Remember: A budget needs both a resource and a reset window, so the same limit has one clear meaning across callbacks and frames.
- Also useful: Locate the failed layer first: unreadable syntax, unsuitable runtime values, rejected host requests, and wrong gameplay decisions need different repairs.
Reading map
- A script can fail without being malicious
- Budgets describe several resources
- Limit memory
- Limit instructions
- See the budget fail on purpose
- Protect every host callback
- Make errors useful but not secret-leaking
- Use a failure policy
- Test more than the happy path
- Sandboxing is a design, not one switch
Review
- Q: Why is an instruction budget not a complete Lua sandbox? A: Memory, time, callbacks and actions each need limits
- Q: What stance should an embedded interpreter take toward script failure? A: Treat it as a normal result; keep the game running
Suppose that helper script encounters a missing unit halfway through a turn. The game still needs to draw the next frame and let the player regain control. The script host therefore needs a clear error report, a bound on work, and a safe way to disable the failing script.
A script can fail without being malicious
Section titled “A script can fail without being malicious”Common mistakes include:
- an infinite loop;
- a table that grows every update;
- calling a missing function;
- assuming an entity always exists;
- dividing by zero and producing a non-finite value;
- repeatedly requesting an action the host rejects.
An embedded interpreter must treat failure as a normal result. One script error should not crash the game, leak resources, or leave input stuck.
Before setting limits, locate where the failure occurred. A syntax error means the source never became a runnable function. A runtime type error means execution reached unsuitable values. A host-API error means the script called the engine but violated that function’s contract. A gameplay error may raise no exception at all: every layer ran, but the requested action was wrong.
The first three can produce diagnostic errors; the fourth needs an assertion about expected behavior. Keep that distinction when choosing what to log and what to test.
Budgets describe several resources
Section titled “Budgets describe several resources”“The script is small” does not imply that it is cheap to run. Bound the dimensions separately:
- source and bytecode size;
- Lua-managed memory;
- VM instructions per update;
- call-frame depth;
- host-callback calls and returned data;
- wall-clock deadline for work outside instruction counting;
- queued actions and log output.
Choose whether a budget resets per callback, per frame, or per whole script session. A limit with no time window is ambiguous. Record which budget stopped execution so a learner can distinguish an infinite loop from memory growth or excessive host calls.
| Resource | Guard | Reset window |
|---|---|---|
| Source and bytecode | Maximum input bytes | Script load |
| Lua-managed memory | Allocator limit | Script session |
| VM instructions | Hook counter | One update |
| Call frames | Maximum depth | One call chain |
| Host callbacks | Call and returned-byte caps | One update |
| Wall-clock work | Host deadline or cancellation | One update |
| Actions and logs | Queue and output-byte caps | One update |
When a limit is crossed, stop that update, discard its uncommitted actions, and report the exact budget. Do not quietly continue with half-produced output.
Limit memory
Section titled “Limit memory”The lab host applies a four-mebibyte Lua allocation limit. The multiplication converts four mebibytes to bytes before the host sets the limit:
let lua = mlua::Lua::new_with( mlua::StdLib::TABLE | mlua::StdLib::STRING | mlua::StdLib::MATH, mlua::LuaOptions::default(),)?;
lua.set_memory_limit(4 * 1024 * 1024)?;Choose a limit after measuring realistic scripts. Too low prevents normal work; unlimited growth lets one bug consume the process.
The limit covers Lua-managed allocations, not every resource a host callback might allocate. Host functions need their own bounds.
Limit instructions
Section titled “Limit instructions”mlua can call a host hook after a chosen number of VM instructions. The hook
may run while Lua is executing, so its counter has to be shared safely with
the Rust closure: Arc shares ownership of the counter, and Mutex protects
one update to it at a time. The clone shares the same counter; it does not
reset the budget.
use std::sync::{Arc, Mutex};use mlua::{HookTriggers, VmState};
let calls = Arc::new(Mutex::new(0_u32));let counter = Arc::clone(&calls);
lua.set_hook( HookTriggers::new().every_nth_instruction(1_000), move |_, _| { let mut calls = counter.lock() .map_err(|_| mlua::Error::RuntimeError("hook counter failed".into()))?; *calls += 1; if *calls > 100 { return Err(mlua::Error::RuntimeError( "script exceeded its instruction budget".into() )); } Ok(VmState::Continue) },)?;Since the hook fires every 1,000 instructions and allows at most 100 calls, a script gets roughly 100,000 VM instructions per update before this particular budget stops it.
The hook is not a stopwatch. Different instructions cost different amounts of real time, and host callbacks can do work between hooks. It is one defense against runaway Lua bytecode.
The host hook counts one call per 1,000 Lua instructions and raises an error on call 101, so a runaway loop is stopped after roughly 100,000 instructions per update. The interactive version needs JavaScript; the lesson text around it explains the same idea.
Predict first
The hook above fires every 1,000 instructions and raises an error when its count passes 100. Suppose the host never sets the count back to 0, and a well-behaved script runs 30,000 instructions in every update.
In which update does the instruction budget stop the script?
Answer: In update 4. 30,000 ÷ 1,000 = 30 hook calls per update. After three updates the count is 30 + 30 + 30 = 90; in update 4 the 11th call makes it 101, more than 100, so the error fires after 11,000 of that update’s instructions.
Hook calls, update by update
Hook calls counted
add to the count
Count (carried over)Result
Each update runs 30,000 instructions, so the hook fires 30,000 ÷ 1,000 = 30 times. Never reset, the count carries over: 30, 60, 90. In update 4, call 11 makes the count 101, more than 100, so the error stops the script after 11,000 of that update’s instructions.
Reset the instruction allowance at a controlled host boundary, not from inside the script. If the hook counter is shared across updates, document that as a session budget; if each update gets a fresh allowance, also cap how often updates can be requested.
See the budget fail on purpose
Section titled “See the budget fail on purpose”scripts/budget_failure.lua contains:
-- ❌ This intentional failure demonstrates the host's instruction budget.while true doendRun it:
cargo run --manifest-path lua-labs/Cargo.toml -- scripts/budget_failure.luaExpected result: the host returns a contextual error naming the script and the exceeded budget. If the terminal hangs forever, the limit is not working.
Protect every host callback
Section titled “Protect every host callback”A function exposed to Lua should validate:
- argument types and string lengths;
- sequence lengths;
- numeric finiteness and range;
- referenced IDs;
- permissions for the current script;
- rate and action budgets.
Never hold a poisoned mutex or important engine lock while running arbitrary script code. Copy inputs first, execute, then validate collected outputs.
Make errors useful but not secret-leaking
Section titled “Make errors useful but not secret-leaking”Development builds should report:
- script path or stable identifier;
- line and traceback when available;
- host API function involved;
- which type, range, permission, or action check failed;
- whether the script was disabled or may retry.
Do not dump process memory, account tokens, full private chat logs, or arbitrary host paths into a public error report.
Use a failure policy
Section titled “Use a failure policy”One practical policy is:
Do not catch an error and continue from half-mutated host state. Lua requests should be queued and committed only after the script returns successfully.
Test more than the happy path
Section titled “Test more than the happy path”Create scripts that:
- request an unknown action;
- omit a required field;
- pass a string where an ID is expected;
- allocate an ever-growing table;
- loop forever;
- throw an error inside a helper function;
- return normally with zero actions;
- submit the maximum allowed valid action list.
For each test, assert the result, action count, error category, and whether the script remains enabled.
Sandboxing is a design, not one switch
Section titled “Sandboxing is a design, not one switch”No single setting makes arbitrary scripts safe. The useful layers are:
- a limited standard library;
- no arbitrary native module loading;
- a narrow host API;
- copied snapshots;
- structured action requests;
- memory, instruction, time, and rate limits;
- least-privilege Windows handles behind the host boundary;
- clear cancellation and recovery.
Lua is most valuable when it makes small policies easy to change while the host keeps powerful capabilities narrow and testable. 🔒
Limit different resources
Why is an instruction budget not a complete Lua sandbox?
Instruction hooks address runaway bytecode. A robust host layers limited libraries, bounded callbacks, memory and time limits, validation, and cancellation.
Finished Lesson 11.7?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.