airsl 0.1.3

Embeddable Lua 5.4 runtime with a capability-gated sandbox and a host standard library
Documentation
# engine-reuse

The lifecycle of one engine: what an evaluation resets, what it leaves behind, and what sharing one
between threads actually buys.

Every other example builds an engine, runs one script and exits — which is exactly the shape that
hides the two facts a dispatch loop depends on. An engine is *not* a fresh sandbox per evaluation,
and an engine shared between threads is a way to avoid rebuilding a state rather than a way to run
scripts at the same time. Neither is visible from a single run, and both are cheap to get wrong.

Like `hello-eval`, the chunks stay inline: the subject here is the Rust API and each chunk is a
line. The `require` section is the exception, because a module cache needs modules on disk — it
writes them into a `tempfile::TempDir` that is removed when the example ends.

## Run

```bash
cargo run -p airsl --example engine-reuse
```

## Output

```
the arg table is written before every evaluation:
  arg[1] = 1 doubles to 2
  arg[1] = 2 doubles to 4
  arg[1] = 3 doubles to 6
  arg[0] is the chunk's own name: reporter

the instruction counter is reset before every evaluation:
  the first evaluation exhausts the budget: script `runaway` exceeded its instruction limit of 100000
  the next evaluation gets the whole ceiling again, and returns 7

the globals a script writes outlive it:
  SESSION, before anything wrote it: nil
  a script named `remember` assigns it
  SESSION, read back by an unrelated script: written by the first script

the require root belongs to the script, not to the engine:
  alpha/main.lua resolves require('lib') against its own directory: alpha
  beta/main.lua resolves require('lib') against its own directory: beta

the require module cache belongs to the engine, not to the script:
  one counting module, required by three evaluations: 1, 1, 1

one engine shared by 4 threads through an Arc:
  thread 0 saw its own argument and doubled it to 0
  thread 1 saw its own argument and doubled it to 2
  thread 2 saw its own argument and doubled it to 4
  thread 3 saw its own argument and doubled it to 6
  evaluations are serialised: sharing avoids rebuilding, it does not parallelise
```

The thread block is sorted by worker id before it is printed. The order four threads finish in is
not this example's to choose, and every `## Output` block here has to reproduce byte for byte.

## What it demonstrates

- **Reuse is the shape, not an optimisation.** Building a state and evaluating on one differ by
  roughly thirty-fold — `benches/eval.rs` times construction, evaluation on a reused engine and
  evaluation on a fresh engine per call, and [docs/README.md]../../docs/README.md tabulates the
  result. Those are a snapshot from one machine at 1000 iterations, not a guarantee; re-take them
  with `cargo bench -p airsl` before relying on them. Nothing is measured *inside* this example,
  because a printed duration would give it a different output on every run.
- **The `arg` table is per evaluation.** `set_arguments` builds a fresh table and assigns the global
  before the chunk loads (`src/engine.rs:260`), so one `Script` cloned with three different
  `with_args` (`src/script.rs:130`) yields three different answers on one engine. `arg[0]` is the
  chunk's own name — Lua's convention for a standalone script, and what a ported shell script reads
  where it read `$0`.
- **The instruction counter is per evaluation.** `eval_to` resets the budget before anything else
  (`src/engine.rs:237`), so the script after a breach gets the whole ceiling rather than what the
  previous one left of it. The alternative fails in a way that depends on what ran before it, which
  is not reproducible from the failing script alone. Tested at `src/engine.rs:619`.
- **The `require` root is per evaluation.** `set_require` decides from the *script's* directory
  (`src/engine.rs:280`), so `alpha/main.lua` and `beta/main.lua` each resolve `require('lib')`
  against their own root on the same engine. A confined script built from source has no directory
  and so gets no `require` at all.
- **The `require` module cache is not.** The table of loaded modules is created once and kept
  (`src/require_loader.rs:99`), keyed by canonical absolute path, so it stays correct across roots.
  Three evaluations of a script requiring a counting module answer `1, 1, 1`, which is what Lua's
  own `package.loaded` does. This used to be the other way round — the cache was rebuilt per
  evaluation and answered `1, 2, 3` — so cached state was discarded while leaked state was kept,
  exactly inverted for a dispatch path.
- **Globals persist, and that is the sharp edge.** Nothing between evaluations restores the
  environment: `mlua`'s one-call version (`Lua::sandbox`) is Luau-only, so isolating successive
  scripts on one engine has to be built rather than borrowed
  ([architecture]../../docs/architecture.md). A script writes `SESSION` and an unrelated chunk
  reads it back. **A dispatch loop that reuses one engine across untrusted scripts is leaking state
  between them** — give one engine scripts that trust each other, and give an untrusted script an
  engine of its own. The memory ceiling has the same shape: it caps the state, not the script, so an
  engine carries earlier scripts' garbage until the collector runs.
- **`Engine` is `Send + Sync`, and sharing is not parallelism.** Four threads hold one engine through
  an `Arc` and each sees its own arguments. That works because the engine takes a lock spanning the
  whole evaluation (`src/engine.rs:64`) rather than relying on `mlua`'s per-operation locking, which
  is memory-safe without being correct across the four steps of an eval — reset the budget, write
  `arg`, install `require`, run the chunk (`src/engine.rs:56`). Before that lock existed, eight
  threads evaluating `return arg[1]` on one engine got another thread's argument 12,247 times out of
  16,000 (`src/engine.rs:59`); the regression test is at `src/engine.rs:664`. Lua on a single state
  cannot execute in parallel however you hold it, so the lock costs an uncontended acquisition and
  no throughput — **a shared engine buys reuse, never concurrency.** For parallelism, build one
  engine per thread and pay construction once each.

The ceiling this example arms is enforced to within the hook's 10,000-instruction check interval
(`src/instruction_budget.rs:34`), which is why `TIGHT_INSTRUCTIONS` is a multiple of it. The figure
in the output is the limit that was set, not a measurement, so it is identical on every platform.

## See also

- [`resource-limits`]../resource-limits/ — arming the ceilings this example only resets.
- [`custom-module`]../custom-module/ — why a `HostModule` must be `Send + Sync`: an engine is
  shareable, so everything installed into one has to be too.
- [architecture]../../docs/architecture.md — the engine-lifecycle table, and what `Engine: Sync`
  does and does not buy.
- [how-to]../../docs/how-to.md — the same two recipes without the scaffolding.