From 1b845f76e3cfbe83be2ffea08e10a26731c89883 Mon Sep 17 00:00:00 2001 From: dompdv <1734209+dompdv@users.noreply.github.com.> Date: Fri, 23 Oct 2026 13:44:30 +0200 Subject: [PATCH 1/5] chore(A51): add plan for deflua suspend/resume Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .agents/plans/A51-deflua-suspend-resume.md | 363 +++++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 .agents/plans/A51-deflua-suspend-resume.md diff --git a/.agents/plans/A51-deflua-suspend-resume.md b/.agents/plans/A51-deflua-suspend-resume.md new file mode 100644 index 0000000..7862e0e --- /dev/null +++ b/.agents/plans/A51-deflua-suspend-resume.md @@ -0,0 +1,363 @@ +--- +id: A51 +title: "Suspend/resume: deflua returns {:suspend, values}, Lua.eval!/call_function surface {:suspended, values, lua}, Lua.resume/2 continues" +issue: null +pr: null +branch: feat/deflua-suspend-resume +base: main +status: ready +direction: A +--- + +## Goal + +Give embedders host-driven suspension of a running Lua evaluation. A +`deflua` function may return `{:suspend, values}` (or +`{:suspend, values, %Lua{}}`) to suspend the VM at that call; the +public entry point (`Lua.eval!/2`, `Lua.call_function/3`) then returns +immediately with `{:suspended, values, %Lua{}}` carrying a resumable +continuation. `Lua.resume/2` (new) re-enters the VM; the resume values +become the suspended call's results, exactly as if the `deflua` had +returned them. + +```elixir +defmodule Scheduler do + use Lua.API, scope: "scheduler" + + deflua yield(value), state do + {:suspend, [value], state} + end +end + +lua = Lua.new() |> Lua.load_api(Scheduler) + +{:suspended, [42], lua} = + Lua.eval!(lua, "local x = scheduler.yield(42); return x + 1") + +{:ok, [11], lua} = Lua.resume(lua, [10]) # the suspended call "returns" 10 +``` + +Why this is tractable: both engines are fully tail-recursive CPS loops +(`Lua.VM.Executor.do_execute/9`, `Lua.VM.Dispatcher.dispatch/11`). The +entire machine state — remaining instructions / pc, register file, +CPS continuation stack, saved call frames, line, instruction tally — +is explicit immutable data in scope at every call site. The BEAM stack +carries no Lua control flow, so "unwinding" a suspended call is just +returning that data. No continuation capture of the Erlang stack is +needed or possible; the VM never relies on it. + +What is missing today (confirmed by reading `Lua.VM.State`, +`lib/lua/vm/state.ex:20-72`): the `%Lua{}` returned by `eval!` carries +the heap (tables, userdata, metatables, upvalue cells) and diagnostic +control state (`call_stack` as `{source, line, name_hint}` traceback +tuples, `call_depth`, `instruction_count`) but **not** the resumable +control state — no pc/rest, no registers, no continuation stack, no +saved frames with their `base`/`result_count`. Resuming from `%Lua{}` +alone is impossible; the continuation must be captured at the call site +and returned alongside. + +## Out of scope + +- The Lua `coroutine` library (`create`/`resume`/`wrap`/`yield`/ + `status`). That is phase 2 and builds on this machinery; `coroutine.lua` + stays a documented suite skip. No suite delta is expected from this + plan. +- Suspension **across a native re-entry boundary**: `pcall`/`xpcall`, + metamethod callbacks (`__index`, `__call`, …), generic-for iterators, + `table.sort` comparators, `gsub` functions, and `deflua` functions + that themselves call back into Lua (`Lua.call_function!`/`eval!`). + v1 raises a catchable Lua error there — "attempt to suspend across a + native call boundary" (Lua 5.2-style yield-across-C semantics). True + Lua 5.3 yield-across-`pcall` needs continuation composition through + Elixir frames, which the BEAM cannot capture; that is phase 2. +- Tick-based / time-sliced suspension (a `State.tick!` hook variant of + `:max_instructions`). Separate plan. +- Persistence/serialization of continuations to disk. Continuations + are pure in-memory terms; a snapshot holding a local fun in a register + is not persistable (documented, not supported). +- A non-bang `Lua.eval/2`. `eval!` itself returns the tagged union. +- Changes to `Lua.Chunk`, the parser, or the compiler. None needed: + suspension is purely a runtime + API feature. + +## Success criteria + +- [ ] A `deflua` returning `{:suspend, values}` / `{:suspend, values, %Lua{}}` + suspends; `Lua.eval!/2` returns `{:suspended, values, lua}` (values + decoded when `decode: true`, default; raw + display-wrapped when + `decode: false`, matching result handling). +- [ ] `Lua.resume/2` returns + `{:ok, results, lua} | {:error, exception, lua} | {:suspended, values, lua}`; + `Lua.resume!/2` returns `{results, lua} | {:suspended, values, lua}` + and raises on error. Resume values are encoded host→Lua values (same + convention as `Lua.call_function/3` args). +- [ ] Resume values become the suspended call's results with correct + multi-return semantics: `local x = yield()`, `local a, b = yield()`, + `return yield()`, `f(yield())` (vararg expansion), and tail position. +- [ ] Suspension works from both engines: interpreted code (executor) and + bytecode-compiled closures (dispatcher), including a suspend that + crosses an Executor↔Dispatcher boundary mid-evaluation. +- [ ] Heap effects made before the suspend are kept (visible in the + suspended `%Lua{}` and after resume) — same §2.3 discipline as + `pcall` error recovery. +- [ ] `:max_instructions` budget spans suspend/resume (tally is captured + in the continuation and re-seeded on resume; resume is not a + top-level eval and does not reset the budget). +- [ ] Suspend across `pcall`/`xpcall` returns the boundary error as an + ordinary protected error (`false, "attempt to suspend across a + native call boundary"`), and does not corrupt state. +- [ ] `Lua.call_function/3` surfaces `{:suspended, values, lua}`; a + native called directly (outside any Lua execution) suspends into a + degenerate continuation whose resume returns the resume values as + the call results. +- [ ] Resuming a `%Lua{}` with `continuation: nil` raises `ArgumentError`. +- [ ] Unencoded suspend values are rejected with the existing + "deflua functions must return encoded data" error. +- [ ] New test file `test/lua/suspend_resume_test.exs` covers the matrix + above; doctests on `Lua.resume/2` pass via `doctest Lua`. +- [ ] `mix format`, `mix compile --warnings-as-errors`, `mix test` pass. +- [ ] `mix test test/lua53_suite_test.exs --only lua53` unchanged + (20/29; `coroutine.lua` remains a documented skip). + +## Implementation notes + +### Public API shapes + +- `%Lua{}` gains a field: `defstruct [:state, :continuation, debug: false]` + (`lib/lua.ex:28`). `continuation` is `nil | Lua.VM.Continuation.t()`. + Additive; `Lua.new/1` leaves it `nil`. +- `Lua.eval!/2,3` spec becomes `{[term()], t()} | {:suspended, [term()], t()}`. + It still raises on errors; suspension is not an error. +- `Lua.call_function/3` spec becomes + `{:ok, [term()], t()} | {:error, Exception.t(), t()} | {:suspended, [term()], t()}`; + `Lua.call_function!/4` mirrors `eval!`. +- New `Lua.resume/2` and `Lua.resume!/2` with full `@doc` + doctests. + Resuming consumes the continuation (the returned `%Lua{}` has + `continuation: nil` unless the run suspends again, in which case the + new continuation replaces it). Because continuations are immutable + data, a host that copies the `%Lua{}` before resuming can branch. +- `deflua` contract extension, documented in `Lua.API` (`lib/lua/api.ex`): + in addition to `value` and `{results, %Lua{}}`, a function may return + `{:suspend, results}` or `{:suspend, results, %Lua{}}`. `results` are + encoded Lua values yielded to the host; they are validated with + `Util.list_encoded?/1` exactly like normal returns. + +### Internal machinery + +**New exception `Lua.VM.Suspend`** (`lib/lua/vm/suspend.ex`, new file): +`defexception values: [], state: nil, layers: nil`. `values` are the +encoded yield values; `state` is the `%Lua.VM.State{}` at the suspend +point (heap effects kept, like VM error exceptions); `layers` is `nil` +until a catch site attaches the continuation layer stack. It is +deliberately **not** one of the exception types `pcall` catches as a Lua +error (`RuntimeError`/`AssertionError`/`TypeError`/`ArgumentError`), and +it must not be wrapped by `Lua.RuntimeException` at the public boundary. +`Exception.message/1` should render something sane for stray cases. + +**New struct `Lua.VM.Continuation`** (`lib/lua/vm/continuation.ex`, new +file): `defstruct [:layers]` — an opaque, unstable stack of machine +layers, innermost first. Each layer is a map: + +- `%{engine: :executor, rest:, regs:, upvalues:, proto:, cont:, frames:, + line:, instruction_count:, base:, result_count:, state:, + open_upvalues:}` — everything `do_execute/9` threads, as in scope at + the executor's call dispatch (`lib/lua/vm/executor.ex:1394-1543`). + Saved frames already carry + `%{rest, cont, regs, upvalues, proto, base, result_count, + open_upvalues}` (`executor.ex:1458-1468`). +- `%{engine: :dispatcher, code:, pc:, regs:, upvalues:, proto:, cont:, + frames:, cs:, cd:, ou:, base:, instruction_count:, state:}` — the + `dispatch/11` parameters as in scope at the dispatcher's bridges + (`lib/lua/vm/dispatcher.ex:2452-2576`). +- `%{engine: :pending_call}` — degenerate layer for a native suspended + when called directly via `Lua.call_function/3` outside any Lua + execution; resume returns the resume values as the call results. + +**Plumbing choice: ride the exception channel, don't change loop return +shapes.** `do_execute/9` and `dispatch/11` return `{results, regs, state}` +/ `{results, state}` through dozens of internal tail-call sites. Threading +a `{:suspended, ...}` variant through all of them would touch the hot +path everywhere. Instead the native-call catch site attaches the layer +stack to the `Suspend` exception and reraises; the exception crosses +the (single) tail-recursive BEAM frame straight to the boundary. This +mirrors the existing error-state ferrying (`annotate_frame_state/2`, +`executor.ex:119-127`): VM exceptions already carry `:state` out of the +loop. `annotate_frame_state/2` only fills `:state` when nil, so a +`Suspend` carrying its own `state` passes through untouched. + +### Catch sites (audit list — every `rescue`/`catch` that can see Suspend) + +| # | Site | Action on `%Lua.VM.Suspend{}` | +|---|------|-------------------------------| +| 1 | Executor native-call branch rescue, `executor.ex:1494-1543` (`{:native_func, fun}` in the `do_execute` call dispatch) | `layers: nil` → build executor layer from in-scope machine state, attach, reraise. `layers` present → reraise `Lua.VM.RuntimeError` "attempt to suspend across a native call boundary" with `state: e.state` (re-entrant native: the fun called back into Lua and the inner machine suspended). | +| 2 | Executor `:compiled_closure` branch, `executor.ex:1394-1433` (bridges via `Dispatcher.execute/4`) | `layers` present → push an executor layer (the outer machine parked at the `Dispatcher.execute` call; resume injects the inner results via `continue_after_call/12`, `executor.ex:3091`). `layers: nil` cannot happen here (only natives raise bare `Suspend`). | +| 3 | Dispatcher native bridges `call_zero_bridge`/`call_one_bridge` (native clauses), `dispatcher.ex:2452-2576` | Same as #1 but build a dispatcher layer. | +| 4 | Dispatcher `:lua_closure` bridge clauses (`dispatcher.ex:2452-2540`) | `layers` present → push a dispatcher layer; resume places results exactly as the bridge's post-call code does (`dispatch(code, pc + 1, regs_with_results, …)`). | +| 5 | `pcall`/`xpcall` rescues, `lib/lua/vm/stdlib.ex:252-283` | Add one explicit clause **before** the catch-all: any `Suspend` → ordinary protected error `{[false, "attempt to suspend across a native call boundary"], State.unwind_to(state, e.state)}` (xpcall: run the message handler on that message, like the generic error path). Rationale: `pcall` is an Elixir native; the pcall'd closure runs in a nested `Executor.call_function/3`, and the Elixir frame between the inner machine and the outer machine is not capturable. This is the deliberate 5.2-style divergence. | +| 6 | `Executor.call_function/3` rescues (`executor.ex:176-180`, `:208-211`) and `Dispatcher.do_execute_top` rescue (`dispatcher.ex:206-213`) | Pass-through (annotate only fills nil `:state`; `Suspend` carries its own). Verify no clause wraps it. | +| 7 | `Lua.VM.execute/3` (`lib/lua/vm.ex`) | Convert `Suspend` with layers → `{:suspended, values, continuation}`; bare `Suspend` (metamethod/iterator context — no catch site built a layer) → raise `Lua.VM.RuntimeError` boundary error. Do **not** reset `instruction_count` on the resume path (see below). | +| 8 | `Lua.eval!` both clauses (`lib/lua.ex:528-620`) | Match the `{:suspended, values, continuation}` return; decode/wrap values per the `:decode` option exactly like normal results; return `{:suspended, values, %{lua | state: ..., continuation: ...}}`. Defensive rescue clause: bare `Suspend` → reraise as `Lua.RuntimeException` boundary error. | +| 9 | `Lua.do_call_function` / `finish_call` (`lib/lua.ex:822-800`, `:855-883`) | `{:native_func, fun}` clause: catch `Suspend` → `{:suspended, values, continuation}` where a bare `Suspend` gets the degenerate `:pending_call` layer. `finish_call` gains a clause wrapping it into `%Lua{}`. The interpreted/compiled clauses convert the engine's `{:suspended, ...}` similarly. | +| 10 | `Lua.eval!`/`call_function!` wrapping rescues (`lib/lua.ex:552-570`, `:935-940`) | `Suspend` must never reach these as a raise (converted at #7/#9); add a defensive clause converting to `Lua.RuntimeException` boundary error if it does. | + +Also audit while implementing: `Lua.API.install/3` callback path, +`require` re-entry (`reset_instructions: false` — a suspend inside a +required module crosses the same native boundary as a re-entrant +`deflua` → boundary error per #1), and `Lua.VM.Stdlib` comparator/gsub +callbacks (they call back via `Executor.call_function/3`; a suspend +there surfaces as a bare `Suspend` at the enclosing native-call catch +→ boundary error per #1 — correct for v1). + +### deflua wrapper changes (`lib/lua.ex`) + +- `execute_function/4` (`lua.ex:1231-1279`) and `wrap_callback/3` + (`lua.ex:1301-1330`): recognize `{:suspend, results}` and + `{:suspend, results, %Lua{}}` alongside the existing return shapes; + validate `results` with `Util.list_encoded?/1` (same error message as + normal returns); then `raise Lua.VM.Suspend, values: results, state: `. +- The `catch thrown_value` in `execute_function` (`lua.ex:1276-1278`) + only catches `throw`, not raises — no change needed. + +### Resume flow + +`Lua.resume(%Lua{continuation: %Continuation{layers: layers}}, values)`: + +1. `layers == nil` → raise `ArgumentError` ("no suspended continuation"). +2. Set `continuation: nil` on the returned `%Lua{}` in every outcome. +3. Run the innermost layer with `values` as the pending call's results: + - `:executor` layer → `Executor.resume_layer/3`: seed + `state.open_upvalues` from the layer (do **not** reset to `%{}` the + way `execute/5` does at `executor.ex:97-98` — the suspended machine + owns live cells), seed `instruction_count` from the layer, then + `continue_after_call(values, regs, rest, upvalues, proto, state, + cont, frames, line, instruction_count, base, result_count)` + (`executor.ex:3091`) — resume values land in registers with correct + `-1`/`-2`/`0`/`1`/`n` result-count semantics, then the loop + continues. Wrap with the same position save/restore hygiene as + `execute/5` (`executor.ex:86-110`). + - `:dispatcher` layer → `Dispatcher.resume_layer/3`: place `values` + into `regs` at `base` exactly as the bridge's post-call code does, + then `dispatch(code, pc + 1, …)`. + - `:pending_call` layer → `{:ok, values, state}` directly. +4. Layer outcomes: `{:ok, results, state}` → pop the layer; if layers + remain, inject `results` into the next layer's pending call (step 3 + again, same engine-specific placement); if none remain, that's the + final result. `{:suspended, values, new_layers}` → return + `{:suspended, values, lua_with_new_continuation}` (a layer stack may + grow: a resumed run can suspend again, possibly in the other engine). + VM error → unwind per the existing `recover_state/2` discipline. +5. `Lua.resume/2` wraps results as `{:ok, results, lua}`; `resume!/2` + raises on error and returns `{results, lua} | + {:suspended, values, lua}`. + +Resume bypasses `Lua.VM.execute/3`'s `reset_instructions` (resume is +not a top-level eval); the layer carries the tally so `:max_instructions` +spans the whole suspend/resume cycle. + +### Files + +- `lib/lua/vm/suspend.ex` — new, `Lua.VM.Suspend` exception. +- `lib/lua/vm/continuation.ex` — new, `Lua.VM.Continuation` struct. +- `lib/lua/vm/executor.ex` — catch clauses (#1, #2), pass-throughs (#6), + new `resume_layer/3`. +- `lib/lua/vm/dispatcher.ex` — catch clauses (#3, #4), pass-through (#6), + new `resume_layer/3`. +- `lib/lua/vm/stdlib.ex` — `pcall`/`xpcall` clause (#5). +- `lib/lua/vm.ex` — boundary conversion (#7). +- `lib/lua.ex` — `%Lua{}` field, `eval!`, `call_function`, + `call_function!`, `do_call_function`/`finish_call`, new `resume/2` + + `resume!/2`, wrapper changes in `execute_function`/`wrap_callback`. +- `lib/lua/api.ex` — `deflua` `@doc` for the `{:suspend, ...}` contract. +- `test/lua/suspend_resume_test.exs` — new, the test matrix below. +- `CHANGELOG.md` — one entry under a new `Unreleased` section (additive + feature; per ROADMAP sequencing this is 1.1.0-bound additive API, not + part of the 1.0.0 freeze). + +### Test matrix (`test/lua/suspend_resume_test.exs`) + +1. Basic suspend: `eval!` returns `{:suspended, [42], lua}`; code after + the call has not run (side-effect flag via another `deflua`). +2. Resume values become the call result (`x = yield(42); return x + 1` + → resume `[10]` → `[11]`). +3. Multi-return placements: single, `local a, b =`, `return yield()`, + `f(yield())` vararg expansion, tail position (`return yield()`). +4. Repeated suspension: a script that suspends three times resumes + three times, accumulating locals across suspends. +5. Suspend deep in a Lua call stack (f → g → yield): frames, locals, + and upvalues resume correctly. +6. Heap effects before suspend are kept: global/table writes visible in + the suspended `%Lua{}` (via `Lua.get!`) and after resume. +7. Error after resume surfaces as `{:error, exception, lua}` / + `resume!` raises with the rich render. +8. Suspend inside a loop (`while`/`for`) — CPS continuation stack + (`cont`) resumes at the right back-edge. +9. `pcall(function() yield() end)` and `pcall(yield, 42)` → + `{false, "attempt to suspend across a native call boundary"}`; + state usable afterwards. +10. Re-entrant `deflua` (calls `Lua.call_function!` on a Lua function + that suspends) → `{:error, exception, lua}` with the boundary + message; catchable by in-Lua `pcall`. +11. Dispatcher path: a bytecode-compiled closure calling `yield` + suspends and resumes (mirror the setup in + `test/lua/vm/dispatcher_test.exs`). +12. Engine-boundary crossing: interpreted code calls a compiled closure + that suspends; resume unwinds both layers and returns the final + results (covers catch sites #2/#4). +13. `Lua.call_function(lua, [:scheduler, :yield], [42])` → + `{:suspended, [42], lua}`; `Lua.resume(lua, [10])` → + `{:ok, [10], lua}` (degenerate `:pending_call` layer). +14. `Lua.resume(Lua.new(), [])` raises `ArgumentError`. +15. `deflua` returning `{:suspend, [unencoded_map]}` → the existing + "deflua functions must return encoded data" error. +16. `:max_instructions` spans suspend/resume: a budget that would be + exceeded only after resume still raises "instruction budget + exceeded" from `resume/2`. +17. `decode: false` → `{:suspended, raw_values, lua}` with display + wrapping matching normal results. +18. Doctests on `Lua.resume/2` (run via `doctest Lua` in + `test/lua_test.exs`). + +## Verification + +```bash +mix format +mix compile --warnings-as-errors +mix test +mix test test/lua53_suite_test.exs --only lua53 +``` + +Expected: `mix test` count goes up by the new file's tests (roughly +20-30), 0 failures. Suite count unchanged at 20/29 — `coroutine.lua` +remains a documented whole-file skip (capability non-goal until the +coroutine phase builds on this). + +## Risks + +- **Layer-stack resume across engine boundaries** is the delicate part. + The dispatcher layer's result placement must match the bridge's + post-call semantics exactly (multi-result calls, `pc + 1` advance). + Test #12 is the guard; do not merge without it. +- **A missed rescue site silently swallows `Suspend`** as a generic + error (the `pcall` catch-all would have done exactly that). The audit + table above is the checklist; test #9/#10 guard the two most likely + holes. +- **Hot path**: all catch clauses live in `rescue` blocks that already + exist at the native-call sites — rescue clauses are zero-cost until + raised, so the happy path is untouched. Verify with + `mix test test/lua/vm/dispatcher_test.exs` and the existing + `max_instructions_test.exs`/`limits_test.exs` (no behaviour change). +- **`open_upvalues` discipline**: `execute/5` resets + `state.open_upvalues` to `%{}` on entry and restores on exit + (`executor.ex:97-109`). The suspend snapshot must capture the live + cells and `resume_layer` must seed them back, not reset — otherwise + closures over suspended locals alias wrong cells (the exact bug class + `A15` fixed at the `require` boundary). +- **API freeze**: this is additive public API (`%Lua{}` field, + `eval!` union, `resume/2`). Per ROADMAP release sequencing it targets + 1.1.0, not the 1.0.0 freeze. The `eval!` spec change is an additive + union; existing `{results, lua} = Lua.eval!(...)` matches keep working + unless a suspend actually fires (then they `MatchError` loudly — + acceptable and documented). +- **Snapshot purity**: layers hold only VM data (tuples/maps/lists and + VM value tags). A native fun captured in a register keeps the snapshot + in-memory-only (fine between processes on one node; not persistable). + Document this on `Lua.VM.Continuation`. From 9b74f186fec4917b4b407c319eba954dee121d67 Mon Sep 17 00:00:00 2001 From: dompdv <1734209+dompdv@users.noreply.github.com.> Date: Fri, 23 Oct 2026 13:46:47 +0200 Subject: [PATCH 2/5] chore(A51): start plan Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .agents/plans/A51-deflua-suspend-resume.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/plans/A51-deflua-suspend-resume.md b/.agents/plans/A51-deflua-suspend-resume.md index 7862e0e..69a2dcd 100644 --- a/.agents/plans/A51-deflua-suspend-resume.md +++ b/.agents/plans/A51-deflua-suspend-resume.md @@ -5,7 +5,7 @@ issue: null pr: null branch: feat/deflua-suspend-resume base: main -status: ready +status: in-progress direction: A --- From 74e8c489dd202ffab332afc83149130c592e78a6 Mon Sep 17 00:00:00 2001 From: dompdv <1734209+dompdv@users.noreply.github.com.> Date: Fri, 23 Oct 2026 15:15:27 +0200 Subject: [PATCH 3/5] chore(A51): record implementation notes Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .agents/plans/A51-deflua-suspend-resume.md | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/.agents/plans/A51-deflua-suspend-resume.md b/.agents/plans/A51-deflua-suspend-resume.md index 69a2dcd..a7c6138 100644 --- a/.agents/plans/A51-deflua-suspend-resume.md +++ b/.agents/plans/A51-deflua-suspend-resume.md @@ -361,3 +361,68 @@ coroutine phase builds on this). VM value tags). A native fun captured in a register keeps the snapshot in-memory-only (fine between processes on one node; not persistable). Document this on `Lua.VM.Continuation`. + +## Implementation notes + +Refinements made while building this (the design above held; these are +the deltas): + +- **Layer stack is innermost-first, built by appending.** Catch sites + fire innermost-first as the `Suspend` exception propagates outward, so + each site *appends* its layer (`layers ++ [layer]`), keeping the head + the machine to run first. The initial draft prepended, which left the + stack outermost-first and resumed the wrong machine — caught by the + VM-level smoke test before any public API existed. +- **The heap flows through the states, not the layers.** + `Continuation.run/3` seeds the innermost layer from the host's + `%Lua{}` state (`seed_first/2`: `instruction_count`, plus + `open_upvalues` for executor layers); each layer's completion state + feeds the next-outer layer, with `transition/2` popping + `call_stack`/`call_depth` only for executor layers parked at the + compiled-closure bridge (`pop_call_frame: true`). Dispatcher layers + need no transition — their bridges continue with loop-carried + `cs`/`cd`/`ou`. +- **`seed_first/2` has a catch-all** for the degenerate + `%{engine: :pending_call}` layer (a native suspended when called + directly via `Lua.call_function/3`): resume values are the results, + nothing to seed. +- **A `deflua`/callback returning `{:suspended, values, lua}` is a + boundary error.** That tuple means a *re-entrant* call suspended (the + deflua called `Lua.call_function/3` on a Lua closure that parked); + returning it cannot compose the inner continuation with the outer + machine (resume would drop the inner machine's captured state), so + `execute_function/4` and `wrap_callback/3` convert it to the boundary + `Lua.VM.RuntimeError` instead of the generic "must return encoded + data" validation error. +- **`run_xpcall_handler` converts a suspending handler** into the + boundary `RuntimeError` (its catch-all would otherwise swallow the + suspend as a handler failure). +- **`parse_and_execute_module` gained an `else` clause** mapping + `Lua.VM.execute/3`'s new `{:suspended, _, _, state}` return to the + boundary `RuntimeError` (a suspend inside a `require`d module crosses + the `require` native boundary). +- **Suspend values are validated exactly like normal returns** + (`Util.list_encoded?/1`, same "deflua functions must return encoded + data" message) in `execute_function/4` and both `wrap_callback/3` + arities; `{:suspend, data}` (no state) raises with the call-entry + state, `{:suspend, data, %Lua{}}` with the threaded state. +- **`eval!` suspend values get the full result treatment**: decoded + (`Value.decode_list/2`) and display-wrapped (`Display.wrap_results/3`) + under the `:decode` option, in both the string and chunk clauses. +- **Audit sweep beyond the plan table**: `Lua.VM.Stdlib` re-entrant + call sites are guarded with `Suspend.guard/1` — `load` reader + (`collect_reader_chunks`), `require` preload loader + (`load_from_preload`), `__tostring` (`value_to_string_with_mt`), + `table.sort` comparator (`invoke_compare`), `string.gsub` replacement + fn. Executor guards: `call_value` (covers generic-for iterators, + `__call`, `dispatcher_call_value`), `invoke_metamethod`, and the + `__index`/`__newindex`/`__call` sites (`call_mm` ×3, index/newindex + wrappers). +- **Verification**: `mix test` 2917 passed (baseline 2870 + 44 new + tests + 3 new doctests), 0 failures; lua53 suite unchanged at + 20/29 (`coroutine.lua` still a documented whole-file skip); + `mix compile --warnings-as-errors` clean; `mix format` clean. +- **Pre-existing quirk (not a regression)**: a generic-for whose + iterator expression is a bare function literal (`for i in function() + ... end`) fails with "attempt to call a number value" on `main` too; + suspend tests use the standard call form (`for i in iter()`). From 56e7ef1ab69348833d55cb22ff388612ee9b1647 Mon Sep 17 00:00:00 2001 From: dompdv <1734209+dompdv@users.noreply.github.com.> Date: Fri, 23 Oct 2026 15:15:37 +0200 Subject: [PATCH 4/5] feat(vm): host-driven suspend/resume for deflua and Elixir callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A deflua or Elixir callback can now return {:suspend, values} (or {:suspend, values, lua} to thread state) to park the running machine. Lua.eval!/2 and Lua.call_function/3 return {:suspended, values, lua} with a continuation on the %Lua{}; the new Lua.resume/2 and Lua.resume!/2 re-enter the parked machine, the resume values becoming the suspended call's results inside Lua. Heap effects made before a suspend are kept, and a :max_instructions budget spans the whole suspend/resume cycle. The suspend travels as a Lua.VM.Suspend exception raised by the callback wrappers; the executor and dispatcher catch it at their native-call and engine-boundary sites and attach resumable machine layers (a stack, innermost first) to the exception, which crosses the tail-recursive BEAM frame to Lua.VM.execute/3 — the same channel VM errors already use to ferry raise-time state out. Continuation.run/3 re-enters the layers innermost-first; the heap flows through the completion states, not the layers. A suspend that cannot be resumed — across pcall/xpcall, a metamethod, a table.sort comparator, a string.gsub replacement function, a generic-for iterator, a load reader, a required module, or a re-entrant native call — is reported as the catchable Lua error "attempt to suspend across a native call boundary". Plan: A51 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- CHANGELOG.md | 21 ++ lib/lua.ex | 315 +++++++++++++++++--- lib/lua/api.ex | 20 ++ lib/lua/vm.ex | 32 +- lib/lua/vm/continuation.ex | 107 +++++++ lib/lua/vm/dispatcher.ex | 203 ++++++++++++- lib/lua/vm/executor.ex | 167 ++++++++++- lib/lua/vm/stdlib.ex | 39 ++- lib/lua/vm/stdlib/string.ex | 5 +- lib/lua/vm/stdlib/table.ex | 5 +- lib/lua/vm/suspend.ex | 76 +++++ test/lua/suspend_resume_test.exs | 481 +++++++++++++++++++++++++++++++ 12 files changed, 1406 insertions(+), 65 deletions(-) create mode 100644 lib/lua/vm/continuation.ex create mode 100644 lib/lua/vm/suspend.ex create mode 100644 test/lua/suspend_resume_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5104040..889268d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,27 @@ is in the [`1.0.0-rc.0`](#100-rc0---2026-05-26) entry below. ## [Unreleased] +### Added +- Host-driven suspension and resumption of a Lua evaluation. A `deflua` + or Elixir callback (set via `Lua.set!/3`, `Lua.encode!/2`, or + `Lua.load_api/2`) can return `{:suspend, values}` — or `{:suspend, + values, lua}` to thread state — to park the running machine instead of + returning. `Lua.eval!/2` and `Lua.call_function/3` then return + `{:suspended, values, lua}` (values decoded and display-wrapped under + the `:decode` option, exactly like normal results), with a continuation + stored on the `%Lua{}`. The new `Lua.resume/2` / `Lua.resume!/2` + re-enter the parked machine; the resume values become the suspended + call's results inside Lua, and a resumed evaluation may suspend again. + Heap effects made before a suspend are kept, as are effects made + before a post-resume error (matching `pcall` semantics), and a + `:max_instructions` budget spans the whole suspend/resume cycle. A + suspend that cannot be resumed — across a `pcall`/`xpcall` boundary, a + metamethod, a `table.sort` comparator, a `string.gsub` replacement + function, a generic-for iterator, a `load` reader, or a re-entrant + native call — is reported as the catchable Lua error `"attempt to + suspend across a native call boundary"`. Full `coroutine` + yield/resume remains a separate non-goal. + ## [1.0.2] - 2026-07-28 ### Changed diff --git a/lib/lua.ex b/lib/lua.ex index e055366..f63b896 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -9,11 +9,13 @@ defmodule Lua do alias Lua.Util alias Lua.VM.AssertionError alias Lua.VM.Bootstrap + alias Lua.VM.Continuation alias Lua.VM.Display alias Lua.VM.Executor alias Lua.VM.InternalError alias Lua.VM.RuntimeError alias Lua.VM.State + alias Lua.VM.Suspend alias Lua.VM.Table alias Lua.VM.TypeError alias Lua.VM.Value @@ -25,7 +27,10 @@ defmodule Lua do # for future-proofing. Suppress dialyzer warnings on those defensive branches. @dialyzer {:no_match, eval!: 2, eval!: 3, parse_chunk: 1} - defstruct [:state, debug: false] + # `:continuation` holds a `Lua.VM.Continuation.t()` while the VM is parked + # at a suspend point (a `deflua`/Elixir callback returned `{:suspend, + # values}`); it is `nil` during normal execution. Resume with `Lua.resume/2`. + defstruct [:state, :continuation, debug: false] @default_sandbox [ [:io, :stdin], @@ -499,6 +504,18 @@ defmodule Lua do iex> {[4], _} = Lua.eval!(~LUA[return 2 + 2]c) + If a `deflua`/Elixir callback suspends execution by returning + `{:suspend, values}` (or `{:suspend, values, lua}`), evaluation parks + instead of running to completion: `eval!/2` returns + `{:suspended, values, lua}` where `lua` carries a continuation. Resume + with `Lua.resume/2`; the values you pass become the suspended call's + results inside Lua. + + iex> lua = Lua.new() |> Lua.set!([:yield], fn [v], lua -> {:suspend, [v], lua} end) + iex> {:suspended, [1], lua} = Lua.eval!(lua, "return yield(1) + 1") + iex> {:ok, [2], _lua} = Lua.resume(lua, [1]) + + ### Options * `:decode` - (default `true`) By default, all values returned from Lua scripts are decoded. This may not be desirable if you need to modify a table reference or access a function call. @@ -509,10 +526,13 @@ defmodule Lua do is a pre-compiled `t:Lua.Chunk.t/0`, `:source` is accepted but ignored — the chunk already carries the source name it was compiled with. """ - @spec eval!(String.t() | Lua.Chunk.t()) :: {[term()], t()} + @spec eval!(String.t() | Lua.Chunk.t()) :: {[term()], t()} | {:suspended, [term()], t()} + @spec eval!(t() | String.t() | Lua.Chunk.t(), String.t() | Lua.Chunk.t() | keyword()) :: - {[term()], t()} - @spec eval!(t(), String.t() | Lua.Chunk.t(), keyword()) :: {[term()], t()} + {[term()], t()} | {:suspended, [term()], t()} + + @spec eval!(t(), String.t() | Lua.Chunk.t(), keyword()) :: + {[term()], t()} | {:suspended, [term()], t()} def eval!(script) do eval!(new(), script, []) end @@ -532,18 +552,33 @@ defmodule Lua do {:ok, ast} -> case Lua.Compiler.compile(ast, source: opts[:source]) do {:ok, proto} -> - {:ok, results, new_state} = Lua.VM.execute(proto, state) - - results = - if opts[:decode] do - Value.decode_list(results, new_state) - else - results - end - - results = Display.wrap_results(results, new_state, opts[:decode]) - - {results, %{lua | state: new_state}} + case Lua.VM.execute(proto, state) do + {:ok, results, new_state} -> + results = + if opts[:decode] do + Value.decode_list(results, new_state) + else + results + end + + results = Display.wrap_results(results, new_state, opts[:decode]) + + {results, %{lua | state: new_state}} + + {:suspended, values, continuation, new_state} -> + # Suspend values cross the same boundary as results: decode + # and display-wrap them under the same `:decode` option. + values = + if opts[:decode] do + Value.decode_list(values, new_state) + else + values + end + + values = Display.wrap_results(values, new_state, opts[:decode]) + + {:suspended, values, %{lua | state: new_state, continuation: continuation}} + end {:error, msg} -> raise Lua.CompilerException, msg @@ -567,6 +602,14 @@ defmodule Lua do e in [RuntimeError, TypeError, AssertionError] -> reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug) + # Defensive: `Lua.VM.execute/3` converts every Suspend it sees (a + # captured one into `{:suspended, ...}`, a bare one into a boundary + # error), so a Suspend here means a boundary was missed. + e in Suspend -> + reraise Lua.RuntimeException, + RuntimeError.exception(value: Suspend.boundary_message(), state: e.state), + prune_lua_internals(__STACKTRACE__, lua.debug) + e -> reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug) end @@ -577,18 +620,31 @@ defmodule Lua do # and that name wins. Pass `:source` at `load_chunk!/2`/compile time to set it. opts = Keyword.validate!(opts, decode: true, source: nil) - {:ok, results, new_state} = Lua.VM.execute(proto, state) + case Lua.VM.execute(proto, state) do + {:ok, results, new_state} -> + results = + if opts[:decode] do + Value.decode_list(results, new_state) + else + results + end + + results = Display.wrap_results(results, new_state, opts[:decode]) - results = - if opts[:decode] do - Value.decode_list(results, new_state) - else - results - end + {results, %{lua | state: new_state}} + + {:suspended, values, continuation, new_state} -> + values = + if opts[:decode] do + Value.decode_list(values, new_state) + else + values + end - results = Display.wrap_results(results, new_state, opts[:decode]) + values = Display.wrap_results(values, new_state, opts[:decode]) - {results, %{lua | state: new_state}} + {:suspended, values, %{lua | state: new_state, continuation: continuation}} + end rescue e in [Lua.RuntimeException, Lua.CompilerException] -> reraise e, __STACKTRACE__ @@ -604,6 +660,12 @@ defmodule Lua do e in [RuntimeError, TypeError, AssertionError] -> reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug) + # Defensive: see the string clause. + e in Suspend -> + reraise Lua.RuntimeException, + RuntimeError.exception(value: Suspend.boundary_message(), state: e.state), + prune_lua_internals(__STACKTRACE__, lua.debug) + e -> reraise Lua.RuntimeException, e, prune_lua_internals(__STACKTRACE__, lua.debug) end @@ -761,7 +823,7 @@ defmodule Lua do """ @spec call_function(t(), term(), [term()]) :: - {:ok, [term()], t()} | {:error, Exception.t(), t()} + {:ok, [term()], t()} | {:error, Exception.t(), t()} | {:suspended, [term()], t()} def call_function(%__MODULE__{} = lua, name, args) do finish_call(lua, resolve_and_call(lua, name, args)) end @@ -777,6 +839,12 @@ defmodule Lua do {:ok, results, %{lua | state: new_state}} end + # A suspended call parks the machine: the continuation rides on the + # returned `%Lua{}` and `Lua.resume/2` re-enters it. + defp finish_call(%__MODULE__{} = lua, {:suspended, values, continuation, new_state}) do + {:suspended, values, %{lua | state: new_state, continuation: continuation}} + end + # Already a host-facing exception (a nested eval, or a compiler error from # `load`) — carry it out unchanged rather than re-wrapping. defp finish_call(%__MODULE__{} = lua, {:error, %Lua.RuntimeException{} = e, new_state}) do @@ -852,7 +920,22 @@ defmodule Lua do {results, new_state} = fun.(args, state) {:ok, List.wrap(results), new_state} rescue - e -> {:error, e, recover_state(e, state)} + e in Suspend -> + case e.layers do + # The native itself suspended, called directly through this + # programmatic boundary (no Lua machine around it): resume + # returns the resume values as the call results. + nil -> + {:suspended, e.values, %Continuation{layers: [%{engine: :pending_call}]}, e.state || recover_state(e, state)} + + # The native re-entered Lua and the inner machine suspended — a + # re-entrant boundary that cannot be resumed from here. + _ -> + {:error, RuntimeError.exception(value: Suspend.boundary_message(), state: e.state), recover_state(e, state)} + end + + e -> + {:error, e, recover_state(e, state)} end defp do_call_function({:lua_closure, proto, upvalues}, args, state) do @@ -870,7 +953,17 @@ defmodule Lua do {:ok, results, new_state} rescue - e -> {:error, e, recover_state(e, state)} + e in Suspend -> + case e.layers do + nil -> + {:error, RuntimeError.exception(value: Suspend.boundary_message(), state: e.state), recover_state(e, state)} + + layers -> + {:suspended, e.values, %Continuation{layers: layers}, e.state || recover_state(e, state)} + end + + e -> + {:error, e, recover_state(e, state)} end defp do_call_function({:compiled_closure, _, _} = closure, args, state) do @@ -879,7 +972,17 @@ defmodule Lua do {results, new_state} = Executor.call_function(closure, args, state) {:ok, results, new_state} rescue - e -> {:error, e, recover_state(e, state)} + e in Suspend -> + case e.layers do + nil -> + {:error, RuntimeError.exception(value: Suspend.boundary_message(), state: e.state), recover_state(e, state)} + + layers -> + {:suspended, e.values, %Continuation{layers: layers}, e.state || recover_state(e, state)} + end + + e -> + {:error, e, recover_state(e, state)} end # A resolved tuple that isn't one of the callable shapes above — e.g. a @@ -918,16 +1021,97 @@ defmodule Lua do end ``` """ - @spec call_function!(t(), term(), [term()]) :: {[term()], t()} + @spec call_function!(t(), term(), [term()]) :: {[term()], t()} | {:suspended, [term()], t()} def call_function!(%__MODULE__{} = lua, func, args) do # Unlike `call_function/3`, the raising variant keeps the rich # `ErrorFormatter` render: re-raising the original VM exception through # `Lua.RuntimeException` runs `Exception.message/1` (the terminal renderer) # and copies its `:line` / `:source` / `:call_stack` onto the wrapper. case resolve_and_call(lua, func, args) do - {:ok, ret, new_state} -> {ret, %{lua | state: new_state}} - {:error, e, _new_state} when is_exception(e) -> raise Lua.RuntimeException, e - {:error, reason, new_state} -> raise Lua.RuntimeException, {:lua_error, reason, new_state} + {:ok, ret, new_state} -> + {ret, %{lua | state: new_state}} + + {:suspended, values, continuation, new_state} -> + {:suspended, values, %{lua | state: new_state, continuation: continuation}} + + {:error, e, _new_state} when is_exception(e) -> + raise Lua.RuntimeException, e + + {:error, reason, new_state} -> + raise Lua.RuntimeException, {:lua_error, reason, new_state} + end + end + + @doc """ + Resumes a suspended evaluation, returning the final values and the + updated Lua environment. + + A `deflua`/Elixir callback suspends execution by returning + `{:suspend, values}` (or `{:suspend, values, lua}`); `Lua.eval!/2` and + `Lua.call_function/3` then return `{:suspended, values, lua}` with a + continuation stored on the `%Lua{}`. `resume/2` re-enters the parked + machine: `values` are the encoded Lua values that become the suspended + call's results (the same encoding convention as `call_function/3` + arguments — plain Elixir values like numbers, strings, and booleans). + + iex> lua = Lua.new() |> Lua.set!([:yield], fn [v], lua -> {:suspend, [v], lua} end) + iex> {:suspended, [1], lua} = Lua.eval!(lua, "return yield(1) * 10") + iex> {:ok, [10], _lua} = Lua.resume(lua, [1]) + + A resumed evaluation may suspend again — `resume/2` returns + `{:suspended, values, lua}` with a fresh continuation — or raise a Lua + error, which this protected boundary returns as `{:error, exception, + lua}` (the continuation is cleared in every outcome). Heap effects made + before the suspend are kept; effects made before a post-resume error are + kept too, matching `pcall` semantics. + + Raises `ArgumentError` when the `%Lua{}` carries no continuation. + + A suspend that cannot be resumed — across a `pcall` boundary, a + metamethod, a `table.sort` comparator, a `string.gsub` replacement + function, or a re-entrant native call — is reported as an ordinary + catchable Lua error: `"attempt to suspend across a native call + boundary"`. + """ + @spec resume(t(), [term()]) :: + {:ok, [term()], t()} | {:error, Exception.t(), t()} | {:suspended, [term()], t()} + def resume(%__MODULE__{continuation: nil}, _values) do + raise ArgumentError, "no suspended continuation: the Lua VM is not parked at a suspend point" + end + + def resume(%__MODULE__{continuation: %Continuation{} = continuation, state: state} = lua, values) do + case Continuation.run(continuation, values, state) do + {:ok, results, new_state} -> + {:ok, results, %{lua | state: new_state, continuation: nil}} + + {:suspended, values, layers, new_state} -> + {:suspended, values, %{lua | state: new_state, continuation: %Continuation{layers: layers}}} + end + rescue + e in [Lua.RuntimeException, Lua.CompilerException] -> + reraise e, __STACKTRACE__ + + e -> + {:error, Lua.RuntimeException.exception(e), %{lua | state: recover_state(e, lua.state), continuation: nil}} + end + + @doc """ + The raising variant of `resume/2`. + + Returns the final values (or `{:suspended, values, lua}` when the + resumed evaluation suspends again) and raises `Lua.RuntimeException` on + a Lua error. + + iex> lua = Lua.new() |> Lua.set!([:yield], fn [v], lua -> {:suspend, [v], lua} end) + iex> {:suspended, _, lua} = Lua.eval!(lua, "return yield(1)") + iex> {[42], _lua} = Lua.resume!(lua, [42]) + """ + @spec resume!(t(), [term()]) :: {[term()], t()} | {:suspended, [term()], t()} + def resume!(%__MODULE__{} = lua, values) do + case resume(lua, values) do + {:ok, results, lua} -> {results, lua} + {:suspended, values, lua} -> {:suspended, values, lua} + {:error, e, _lua} -> raise e end end @@ -1244,6 +1428,42 @@ defmodule Lua do scope: module.scope(), message: "maps must be explicitly encoded to tables using Lua.encode!/2" + # Suspend: the callback parks the machine. Raising `Lua.VM.Suspend` + # unwinds to the nearest VM call boundary, which captures the machine + # state as a resumable continuation. Validated exactly like normal + # returns. + {:suspend, data} -> + data = List.wrap(data) + + if not Util.list_encoded?(data) do + raise Lua.RuntimeException, + function: function_name, + scope: module.scope(), + message: "deflua functions must return encoded data, got #{inspect(data)}" + end + + raise Suspend, values: data, state: lua.state + + {:suspend, data, %Lua{} = returned_lua} -> + data = List.wrap(data) + + if not Util.list_encoded?(data) do + raise Lua.RuntimeException, + function: function_name, + scope: module.scope(), + message: "deflua functions must return encoded data, got #{inspect(data)}" + end + + raise Suspend, values: data, state: returned_lua.state + + # A deflua that re-enters Lua (`Lua.call_function/3` on a closure + # that suspends) gets `{:suspended, values, lua}` back. Returning it + # cannot compose the inner continuation with the outer machine — + # resuming would drop the inner machine's captured state — so it is + # the Lua 5.2-style boundary error, not a silent propagation. + {:suspended, _values, %Lua{} = returned_lua} -> + raise RuntimeError, value: Suspend.boundary_message(), state: returned_lua.state + {:error, reason} -> raise RuntimeError, value: reason @@ -1301,9 +1521,17 @@ defmodule Lua do defp wrap_callback(func, function_name, scope) when is_function(func, 1) do {:native_func, fn args, state -> - return = List.wrap(func.(args)) - validate_encoded!(return, function_name, scope) - {return, state} + case func.(args) do + {:suspend, data} -> + data = List.wrap(data) + validate_encoded!(data, function_name, scope) + raise Suspend, values: data, state: state + + return -> + return = List.wrap(return) + validate_encoded!(return, function_name, scope) + {return, state} + end end} end @@ -1311,6 +1539,21 @@ defmodule Lua do {:native_func, fn args, state -> case func.(args, wrap(state)) do + {:suspend, data} -> + data = List.wrap(data) + validate_encoded!(data, function_name, scope) + raise Suspend, values: data, state: state + + {:suspend, data, %__MODULE__{} = returned_lua} -> + data = List.wrap(data) + validate_encoded!(data, function_name, scope) + raise Suspend, values: data, state: returned_lua.state + + # See execute_function/4: a re-entrant suspend is a boundary + # error, not a propagation. + {:suspended, _values, %__MODULE__{} = returned_lua} -> + raise RuntimeError, value: Suspend.boundary_message(), state: returned_lua.state + {:error, reason, %__MODULE__{} = returned_lua} -> raise RuntimeError, value: reason, state: returned_lua.state diff --git a/lib/lua/api.ex b/lib/lua/api.ex index dc19789..1bc3447 100644 --- a/lib/lua/api.ex +++ b/lib/lua/api.ex @@ -234,6 +234,26 @@ defmodule Lua.API do > reset this attribute after every function definition, so there is no need to > manually reset it yourself + ## Suspending execution + + A `deflua` can suspend the running Lua evaluation by returning + `{:suspend, values}` (or `{:suspend, values, lua}` to thread state, like a + normal return). `Lua.eval!/2` / `Lua.call_function/3` then return + `{:suspended, values, lua}` instead of raising, and the host resumes the + parked machine with `Lua.resume/2` — the values passed to `resume/2` + become the suspended call's results inside Lua. + + deflua yield(value), state do + {:suspend, [value], state} + end + + Suspend values must be encoded Lua values, exactly like normal return + values. A suspend that cannot be resumed — across a `pcall` boundary, a + metamethod, a `table.sort` comparator, a `string.gsub` replacement + function, or a `deflua` that re-enters Lua and returns the inner + `{:suspended, ...}` tuple — is reported as the catchable Lua error + `"attempt to suspend across a native call boundary"`. + """ defmacro deflua(fa, state, rest) do name = diff --git a/lib/lua/vm.ex b/lib/lua/vm.ex index 27467d4..9382025 100644 --- a/lib/lua/vm.ex +++ b/lib/lua/vm.ex @@ -10,13 +10,21 @@ defmodule Lua.VM do """ alias Lua.Compiler.Prototype + alias Lua.VM.Continuation alias Lua.VM.Executor alias Lua.VM.State + alias Lua.VM.Suspend @doc """ Executes a compiled prototype. - Returns {:ok, results, state} on success. + Returns `{:ok, results, state}` on success, or `{:suspended, values, + continuation, state}` when a native callback suspends execution with + `{:suspend, values}` — `continuation` captures the parked machines and + `values` are the encoded values yielded to the host. A bare `Suspend` + that reaches this boundary without captured layers (a suspend in a + metamethod/iterator context, where no machine frame can be parked) is + raised as a `Lua.VM.RuntimeError` boundary error. ## Options @@ -28,7 +36,8 @@ defmodule Lua.VM do function — must pass `false` so the module body accumulates against the one per-evaluation budget instead of resetting it. """ - @spec execute(Prototype.t(), State.t(), keyword()) :: {:ok, list(), State.t()} + @spec execute(Prototype.t(), State.t(), keyword()) :: + {:ok, list(), State.t()} | {:suspended, list(), Continuation.t(), State.t()} def execute(%Prototype{} = proto, state \\ State.new(), opts \\ []) do # Size the register file to the prototype's honest register peak, with no # slack buffer — same contract as every other call frame. `max_registers` @@ -50,9 +59,22 @@ defmodule Lua.VM do state = if Keyword.get(opts, :reset_instructions, true), do: %{state | instruction_count: 0}, else: state # Execute the prototype instructions - {results, _final_regs, final_state} = - Executor.execute(proto.instructions, registers, [], proto, state) + try do + {results, _final_regs, final_state} = + Executor.execute(proto.instructions, registers, [], proto, state) - {:ok, results, final_state} + {:ok, results, final_state} + rescue + e in Suspend -> + case e.layers do + nil -> + # No catch site captured a machine (metamethod/iterator + # context): the suspend is not resumable. + Suspend.raise_boundary!(e, fallback_state: state) + + layers -> + {:suspended, e.values, %Continuation{layers: layers}, e.state || state} + end + end end end diff --git a/lib/lua/vm/continuation.ex b/lib/lua/vm/continuation.ex new file mode 100644 index 0000000..83c7fb4 --- /dev/null +++ b/lib/lua/vm/continuation.ex @@ -0,0 +1,107 @@ +defmodule Lua.VM.Continuation do + @moduledoc """ + Opaque resumable machine state for a suspended Lua evaluation. + + A continuation is a stack of machine layers, innermost first, captured when + a `deflua` / Elixir callback suspends execution with `{:suspend, values}`. + Each layer is a plain map holding one engine's loop state at the suspended + call site: + + * `%{engine: :executor, ...}` — the interpreter's `do_execute/9` state + (`rest`, `regs`, `upvalues`, `proto`, `cont`, `frames`, `line`, + `instruction_count`, `base`, `result_count`, `open_upvalues`, and + `pop_call_frame` when the layer was parked at an engine-boundary + bridge). + * `%{engine: :dispatcher, ...}` — the dispatcher's `dispatch/11` state + (`code`, `pc`, `regs`, `upvalues`, `proto`, `cont`, `frames`, `cs`, + `cd`, `ou`, `placement`, `instruction_count`). + * `%{engine: :pending_call}` — a native suspended when called directly + via `Lua.call_function/3` outside any Lua execution; resume returns + the resume values as the call results. + + The heap flows through the run, not through the layers: resuming seeds the + innermost layer from the host's `%Lua{}` state, and each layer's + completion state feeds the next-outer layer (applying the boundary + bookkeeping the parked bridge would have done on the normal return path). + + > #### Unstable internals {: .warning} + > The layer shapes are VM internals and may change between releases. A + > continuation is an immutable, in-memory term: it can be passed between + > processes on one node and copied to branch, but it is not persistable + > when a register holds a local fun. + """ + + alias Lua.VM.State + + defstruct [:layers] + + @type t :: %__MODULE__{layers: [map()]} + + @doc """ + Runs the continuation until it completes or suspends again. + + `values` are the encoded resume values: they become the suspended call's + results in the innermost layer. Returns `{:ok, results, state}` when the + outermost layer completes, or `{:suspended, values, layers, state}` when a + layer suspends again (`layers` is the replacement layer stack, already + re-composed with any remaining outer layers). + """ + @spec run(t(), [term()], State.t()) :: + {:ok, [term()], State.t()} | {:suspended, [term()], [map()], State.t()} + def run(%__MODULE__{layers: [first | rest]}, values, state) do + run_from(first, rest, values, seed_first(first, state)) + end + + defp run_from(layer, rest, values, state) do + case run_layer(layer, values, state) do + {:ok, results, state} -> + case rest do + [] -> + {:ok, results, state} + + [outer | outer_rest] -> + run_from(outer, outer_rest, results, transition(rest, state)) + end + + {:suspended, values, layers, state} -> + {:suspended, values, layers ++ rest, state} + end + end + + defp run_layer(%{engine: :executor} = layer, values, state), do: Lua.VM.Executor.resume_layer(layer, values, state) + + defp run_layer(%{engine: :dispatcher} = layer, values, state), do: Lua.VM.Dispatcher.resume_layer(layer, values, state) + + # A native suspended when called directly through the programmatic + # boundary (no Lua machine around it): the resume values are the results. + defp run_layer(%{engine: :pending_call}, values, state), do: {:ok, values, state} + + # The innermost layer seeds the machine's budget tally and open upvalue + # cells from its capture — the host's state may carry a tally from an + # unrelated later evaluation. Outer layers are seeded by the inner layer's + # completion state instead, which carries the fresh tally (and the + # restored open cells) the normal return path would have produced. + defp seed_first(%{engine: :executor, instruction_count: instruction_count, open_upvalues: open_upvalues}, state) do + %{state | instruction_count: instruction_count, open_upvalues: open_upvalues} + end + + defp seed_first(%{instruction_count: instruction_count}, state) do + %{state | instruction_count: instruction_count} + end + + # A degenerate `:pending_call` layer carries no machine state — the resume + # values are the call results, nothing to seed. + defp seed_first(_layer, state), do: state + + # An executor layer parked at the compiled-closure bridge expects the state + # the bridge would have computed on the normal return path: the + # dispatcher's terminal stamps the call frame it was entered with back + # into the struct, and the branch pops it before continuing. Dispatcher + # layers need no such adjustment — their bridges continue with the + # loop-carried `cs`/`cd`/`ou` params, not the struct's fields. + defp transition([%{engine: :executor, pop_call_frame: true} | _], state) do + %{state | call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} + end + + defp transition(_rest, state), do: state +end diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index e04d96b..a187493 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -26,6 +26,7 @@ defmodule Lua.VM.Dispatcher do alias Lua.VM.Numeric alias Lua.VM.RuntimeError alias Lua.VM.State + alias Lua.VM.Suspend alias Lua.VM.Table # Mirror of the interpreter's concat ceiling, inlined as a compile-time @@ -173,6 +174,67 @@ defmodule Lua.VM.Dispatcher do do_execute_top(proto, args, upvalues, state) end + @doc false + # Re-enters the dispatch loop at a captured continuation layer: the resume + # `values` are placed as the suspended call's results exactly as the parked + # bridge's post-call code would have placed them (`:zero` discards, + # `{:one, base}` keeps one, `{:multi, base, count}` spreads via + # `apply_multi_call_result/15`). The caller (`Lua.VM.Continuation.run/3`) + # seeds `state.instruction_count` from the layer for the innermost machine + # and from the inner layer's completion state for outer ones. + @spec resume_layer(map(), [term()], State.t()) :: + {:ok, [term()], State.t()} | {:suspended, [term()], [map()], State.t()} + def resume_layer(layer, values, state) do + %{ + code: code, + pc: pc, + regs: regs, + upvalues: upvalues, + proto: proto, + cont: cont, + frames: frames, + cs: cs, + cd: cd, + ou: ou, + placement: placement + } = layer + + instruction_count = state.instruction_count + + {results, state} = + case placement do + :zero -> + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + {:one, base} -> + regs = :erlang.setelement(base + 1, regs, first_result(values)) + dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) + + {:multi, base, result_count} -> + apply_multi_call_result( + result_count, + base, + values, + code, + pc + 1, + regs, + upvalues, + proto, + state, + cont, + frames, + instruction_count, + cs, + cd, + ou + ) + end + + {:ok, results, state} + rescue + e in Suspend -> {:suspended, e.values, e.layers, e.state || state} + end + defp do_execute_top(proto, args, upvalues, state) do regs = init_regs(proto, args) @@ -1898,7 +1960,30 @@ defmodule Lua.VM.Dispatcher do instruction_count: instruction_count } - {results, state} = Executor.call_function(closure, args, state) + {results, state} = + try do + Executor.call_function(closure, args, state) + rescue + e in Suspend -> + layer = + dispatcher_layer( + {:multi, base, result_count}, + code, + pc, + regs, + upvalues, + proto, + cont, + frames, + cs, + cd, + ou, + instruction_count + ) + + reraise bridge_suspend(e, layer, :closure), __STACKTRACE__ + end + instruction_count = state.instruction_count apply_multi_call_result( @@ -1925,7 +2010,28 @@ defmodule Lua.VM.Dispatcher do state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} {results, state} = - Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + try do + Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + rescue + e in Suspend -> + layer = + dispatcher_layer( + {:multi, base, result_count}, + code, + pc, + regs, + upvalues, + proto, + cont, + frames, + cs, + cd, + ou, + instruction_count + ) + + reraise bridge_suspend(e, layer, :native), __STACKTRACE__ + end instruction_count = state.instruction_count @@ -2477,7 +2583,17 @@ defmodule Lua.VM.Dispatcher do instruction_count: instruction_count } - {_results, state} = Executor.call_function(closure, args, state) + {_results, state} = + try do + Executor.call_function(closure, args, state) + rescue + e in Suspend -> + layer = + dispatcher_layer(:zero, code, pc, regs, upvalues, proto, cont, frames, cs, cd, ou, instruction_count) + + reraise bridge_suspend(e, layer, :closure), __STACKTRACE__ + end + instruction_count = state.instruction_count dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) end @@ -2502,7 +2618,16 @@ defmodule Lua.VM.Dispatcher do ) do state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} - {_results, state} = Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + {_results, state} = + try do + Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + rescue + e in Suspend -> + layer = + dispatcher_layer(:zero, code, pc, regs, upvalues, proto, cont, frames, cs, cd, ou, instruction_count) + + reraise bridge_suspend(e, layer, :native), __STACKTRACE__ + end instruction_count = state.instruction_count dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) @@ -2538,7 +2663,17 @@ defmodule Lua.VM.Dispatcher do instruction_count: instruction_count } - {results, state} = Executor.call_function(closure, args, state) + {results, state} = + try do + Executor.call_function(closure, args, state) + rescue + e in Suspend -> + layer = + dispatcher_layer({:one, base}, code, pc, regs, upvalues, proto, cont, frames, cs, cd, ou, instruction_count) + + reraise bridge_suspend(e, layer, :closure), __STACKTRACE__ + end + instruction_count = state.instruction_count regs = :erlang.setelement(base + 1, regs, first_result(results)) dispatch(code, pc + 1, regs, upvalues, proto, state, cont, frames, instruction_count, cs, cd, ou) @@ -2565,7 +2700,16 @@ defmodule Lua.VM.Dispatcher do ) do state = %{state | call_stack: cs, call_depth: cd, instruction_count: instruction_count} - {results, state} = Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + {results, state} = + try do + Executor.dispatcher_call_function(func_value, args, state, proto, name_hint, line) + rescue + e in Suspend -> + layer = + dispatcher_layer({:one, base}, code, pc, regs, upvalues, proto, cont, frames, cs, cd, ou, instruction_count) + + reraise bridge_suspend(e, layer, :native), __STACKTRACE__ + end instruction_count = state.instruction_count regs = :erlang.setelement(base + 1, regs, first_result(results)) @@ -2575,6 +2719,53 @@ defmodule Lua.VM.Dispatcher do defp first_result([v | _]), do: v defp first_result([]), do: nil + # Builds this machine's continuation layer for a bridge call with the + # given result placement (`:zero` discards the results, `{:one, base}` + # keeps one, `{:multi, base, count}` spreads). Built only on the suspend + # path — the happy path pays nothing for it. + defp dispatcher_layer(placement, code, pc, regs, upvalues, proto, cont, frames, cs, cd, ou, instruction_count) do + %{ + engine: :dispatcher, + code: code, + pc: pc, + regs: regs, + upvalues: upvalues, + proto: proto, + cont: cont, + frames: frames, + cs: cs, + cd: cd, + ou: ou, + placement: placement, + instruction_count: instruction_count + } + end + + # Catch-site logic for `Lua.VM.Suspend` at a dispatcher call bridge. The + # bridge is an engine boundary: a suspend carrying layers means the bridged + # callee's machine captured itself, so push this machine's layer on top — + # resume unwinds the stack innermost-first. A bare suspend means the bridged + # native itself suspended (capture this machine as the innermost layer); + # on a closure bridge that cannot legitimately happen (the nested machine's + # own catch sites always capture first), so it is a boundary error. For a + # native bridge carrying layers, the native re-entered Lua and the inner + # machine suspended — a re-entrant boundary that cannot resume — so it is + # also a boundary error. + # + # Returns the `Lua.VM.Suspend` exception to reraise (layer stack updated), + # or raises the boundary `Lua.VM.RuntimeError`. + defp bridge_suspend(%Suspend{layers: nil} = e, layer, :native), do: %{e | layers: [layer]} + + defp bridge_suspend(%Suspend{} = e, _layer, :native), do: Suspend.raise_boundary!(e, fallback_state: e.state) + + # Append, not prepend: catch sites fire innermost-first as the exception + # propagates outward, so the layer stack stays innermost-first — resume + # runs the head layer first. + defp bridge_suspend(%Suspend{layers: layers} = e, layer, :closure) when is_list(layers), + do: %{e | layers: layers ++ [layer]} + + defp bridge_suspend(%Suspend{} = e, _layer, :closure), do: Suspend.raise_boundary!(e, fallback_state: e.state) + defp init_callee_regs(callee_proto, src_regs, src_off, arg_count) do # Exact-sized like `init_regs/2`; runs on every compiled-closure call, # so sizing to the callee's honest register peak (no slack) is what keeps diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index ccad361..5327cbd 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -22,6 +22,7 @@ defmodule Lua.VM.Executor do alias Lua.VM.Numeric alias Lua.VM.RuntimeError alias Lua.VM.State + alias Lua.VM.Suspend alias Lua.VM.Table alias Lua.VM.TypeError alias Lua.VM.Value @@ -104,6 +105,53 @@ defmodule Lua.VM.Executor do end end + @doc false + # Re-enters the interpreter at a captured continuation layer: the resume + # `values` become the suspended call's results, placed by + # `continue_after_call/12` with the captured `base`/`result_count` so + # single/multi/vararg/tail placements all resume correctly. The caller + # (`Lua.VM.Continuation.run/3`) seeds `state.instruction_count` from the + # layer for the innermost machine and from the inner layer's completion + # state for outer ones. + @spec resume_layer(map(), [term()], State.t()) :: + {:ok, [term()], State.t()} | {:suspended, [term()], [map()], State.t()} + def resume_layer(layer, values, state) do + %{ + rest: rest, + regs: regs, + upvalues: upvalues, + proto: proto, + cont: cont, + frames: frames, + line: line, + base: base, + result_count: result_count, + open_upvalues: open_upvalues + } = layer + + state = %{state | open_upvalues: open_upvalues} + + {results, _regs, state} = + continue_after_call( + values, + regs, + rest, + upvalues, + proto, + state, + cont, + frames, + line, + state.instruction_count, + base, + result_count + ) + + {:ok, results, state} + rescue + e in Suspend -> {:suspended, e.values, e.layers, e.state || state} + end + defp set_position(line, source) do # Single tuple in the process dict keeps the position bridge to one # `Process.put`/`Process.get` pair per native callback instead of two. @@ -241,7 +289,7 @@ defmodule Lua.VM.Executor do state: state call_mm -> - call_function(call_mm, [other | args], state) + Suspend.guard(fn -> call_function(call_mm, [other | args], state) end) end end end @@ -622,7 +670,7 @@ defmodule Lua.VM.Executor do state: state call_mm -> - call_function(call_mm, [other | args], state) + Suspend.guard(fn -> call_function(call_mm, [other | args], state) end) end end end @@ -1412,7 +1460,48 @@ defmodule Lua.VM.Executor do instruction_count: instruction_count } - {results, state} = Dispatcher.execute(callee_proto, args, callee_upvalues, state) + {results, state} = + try do + Dispatcher.execute(callee_proto, args, callee_upvalues, state) + rescue + e in Suspend -> + # A suspend escaping the dispatcher carries the inner machine's + # captured layers: this interpreter machine was parked at the + # compiled-closure call, so push its layer on top and keep + # propagating. Resume unwinds the stack innermost-first and + # injects each layer's results into the next-outer pending call. + # A bare suspend cannot legitimately reach here (the callee is + # a compiled closure, not a native); treat it as a boundary + # error. + case e.layers do + nil -> + Suspend.raise_boundary!(e, fallback_state: state, line: line, source: proto.source) + + layers -> + layer = %{ + engine: :executor, + rest: rest, + regs: regs, + upvalues: upvalues, + proto: proto, + cont: cont, + frames: frames, + line: line, + instruction_count: instruction_count, + base: base, + result_count: result_count, + open_upvalues: state.open_upvalues, + pop_call_frame: true + } + + # Append, not prepend: catch sites fire innermost-first as + # the exception propagates outward, so prepending would + # leave the stack outermost-first — resume must run the + # innermost (head) layer first. + reraise %{e | layers: layers ++ [layer]}, __STACKTRACE__ + end + end + instruction_count = state.instruction_count state = %{state | call_stack: tl(state.call_stack), call_depth: state.call_depth - 1} @@ -1523,7 +1612,41 @@ defmodule Lua.VM.Executor do value: "native function returned invalid result: #{inspect(other)}, expected {results, state}" end rescue - e -> reraise annotate_frame_state(e, state), __STACKTRACE__ + e in Suspend -> + # A bare suspend means this native callback itself suspended: + # capture the whole machine (remaining instructions, registers, + # CPS continuation stack, call frames) as the innermost + # continuation layer and keep propagating to the boundary. A + # suspend that already carries layers came from a nested + # execution inside the callback — a re-entrant native + # boundary that cannot be resumed — so it becomes a catchable + # Lua error (Lua 5.2-style "yield across a C-call boundary"). + case e.layers do + nil -> + layer = %{ + engine: :executor, + rest: rest, + regs: regs, + upvalues: upvalues, + proto: proto, + cont: cont, + frames: frames, + line: line, + instruction_count: instruction_count, + base: base, + result_count: result_count, + open_upvalues: state.open_upvalues, + pop_call_frame: false + } + + reraise %{e | layers: [layer]}, __STACKTRACE__ + + _ -> + Suspend.raise_boundary!(e, fallback_state: state, line: line, source: proto.source) + end + + e -> + reraise annotate_frame_state(e, state), __STACKTRACE__ after restore_position(prev_pos) end @@ -1584,7 +1707,10 @@ defmodule Lua.VM.Executor do call_mm -> args = collect_args(regs, base + 1, total_args) state = %{state | instruction_count: instruction_count} - {results, state} = call_function(call_mm, [other | args], state) + + {results, state} = + Suspend.guard(fn -> call_function(call_mm, [other | args], state) end) + instruction_count = state.instruction_count continue_after_call( @@ -3176,7 +3302,16 @@ defmodule Lua.VM.Executor do # ── call_value — invoke a function for generic_for iterator ─────────────── - defp call_value({:lua_closure, callee_proto, callee_upvalues}, args, _proto, state, _line) do + # Metamethod (`__call`) and generic-for iterator invocations have no + # continuation capture at their call site, so a suspend escaping the + # callee is a boundary error, not a resumable suspension. + defp call_value(callable, args, proto, state, line) do + do_call_value(callable, args, proto, state, line) + rescue + e in Suspend -> Suspend.raise_boundary!(e, fallback_state: state) + end + + defp do_call_value({:lua_closure, callee_proto, callee_upvalues}, args, _proto, state, _line) do callee_regs = Tuple.duplicate(nil, max(callee_proto.max_registers, callee_proto.param_count)) @@ -3221,13 +3356,13 @@ defmodule Lua.VM.Executor do end end - defp call_value({:compiled_closure, _, _} = closure, args, _proto, state, _line) do + defp do_call_value({:compiled_closure, _, _} = closure, args, _proto, state, _line) do # Compiled-closure callees for generic_for iterators reuse the same # dispatcher bridge as `call_function/3`. call_function(closure, args, state) end - defp call_value({:native_func, fun}, args, proto, state, line) do + defp do_call_value({:native_func, fun}, args, proto, state, line) do # Same source-position bridge as the `:call` opcode's native dispatch. # Used by `for` loop iteration when the iterator is native. prev_pos = Process.get(@position_key, @unset) @@ -3253,7 +3388,7 @@ defmodule Lua.VM.Executor do end end - defp call_value(nil, _args, proto, state, line) do + defp do_call_value(nil, _args, proto, state, line) do raise TypeError, value: "attempt to call a nil value", source: proto.source, @@ -3264,7 +3399,7 @@ defmodule Lua.VM.Executor do state: state end - defp call_value(other, args, proto, state, line) do + defp do_call_value(other, args, proto, state, line) do case get_metatable(other, state) do nil -> raise TypeError, @@ -3365,7 +3500,9 @@ defmodule Lua.VM.Executor do table_index(idx_tbl, key, state) func when is_tuple(func) -> - {results, state} = call_function(func, [value, key], state) + {results, state} = + Suspend.guard(fn -> call_function(func, [value, key], state) end) + {List.first(results), state} end end @@ -3487,7 +3624,9 @@ defmodule Lua.VM.Executor do table_index(index_table, key, state, depth + 1) func when is_tuple(func) -> - {results, state} = call_function(func, [{:tref, id}, key], state) + {results, state} = + Suspend.guard(fn -> call_function(func, [{:tref, id}, key], state) end) + {List.first(results), state} end end @@ -3544,7 +3683,9 @@ defmodule Lua.VM.Executor do table_newindex(newindex_table, key, value, state, depth + 1) func when is_tuple(func) -> - {_results, state} = call_function(func, [{:tref, id}, key, value], state) + {_results, state} = + Suspend.guard(fn -> call_function(func, [{:tref, id}, key, value], state) end) + state end end diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index 6967692..613e602 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -14,6 +14,7 @@ defmodule Lua.VM.Stdlib do alias Lua.VM.ProtectedCall alias Lua.VM.RuntimeError alias Lua.VM.State + alias Lua.VM.Suspend alias Lua.VM.Table alias Lua.VM.TypeError alias Lua.VM.Value @@ -253,6 +254,13 @@ defmodule Lua.VM.Stdlib do {results, state} = Executor.call_function(func, args, state) {[true | results], state} rescue + # A suspend crossing the pcall boundary cannot be resumed: the Elixir + # frame between the pcall'd closure's machine and the outer machine is + # not capturable. It becomes an ordinary protected error (the Lua 5.2 + # "yield across a C-call boundary" divergence). + e in Suspend -> + {[false, Suspend.boundary_message()], State.unwind_to(state, e.state)} + e in [RuntimeError, AssertionError, TypeError, ArgumentError] -> {[false, ProtectedCall.error_value(e)], State.unwind_to(state, e.state)} @@ -273,6 +281,11 @@ defmodule Lua.VM.Stdlib do {results, state} = Executor.call_function(func, args, state) {[true | results], state} rescue + # Same boundary as pcall: a suspend is reported to the message handler + # like any other error value. + e in Suspend -> + run_xpcall_handler(handler, Suspend.boundary_message(), State.unwind_to(state, e.state)) + e in [RuntimeError, AssertionError, TypeError, ArgumentError] -> run_xpcall_handler(handler, ProtectedCall.error_value(e), State.unwind_to(state, e.state)) @@ -289,6 +302,12 @@ defmodule Lua.VM.Stdlib do {handler_results, state} = Executor.call_function(handler, [error_msg], state) {[false | handler_results], state} rescue + # A handler that suspends crosses the same native boundary: report it + # as an ordinary boundary error rather than swallowing the suspend as + # a handler failure. + e in Suspend -> + raise RuntimeError, value: Suspend.boundary_message(), state: e.state + e -> {[false, error_msg], State.unwind_to(state, raised_state(e))} end @@ -503,7 +522,9 @@ defmodule Lua.VM.Stdlib do defp load_env_arg(_rest, state), do: State.g_ref(state) defp collect_reader_chunks(reader, state, acc, size) do - {results, state} = Executor.call_function(reader, [], state) + # A reader that suspends cannot be resumed here: the loop's own frame is + # not a VM machine frame, so there is no continuation to capture. + {results, state} = Suspend.guard(fn -> Executor.call_function(reader, [], state) end) case results do [] -> @@ -735,7 +756,9 @@ defmodule Lua.VM.Stdlib do # and cache its result, mirroring `parse_and_execute_module`. defp load_from_preload(modname, loader, state) do state = cache_module_result(state, modname, true) - {results, state} = Executor.call_function(loader, [modname], state) + # A preload loader that suspends cannot be resumed here — same native + # boundary as a re-entrant deflua. + {results, state} = Suspend.guard(fn -> Executor.call_function(loader, [modname], state) end) result = case results do @@ -793,6 +816,12 @@ defmodule Lua.VM.Stdlib do state = cache_module_result(state, modname, result) {[result], state} else + # A suspend inside a required module crosses the `require` native + # boundary — no continuation can be captured for the module's + # machine, so it is a boundary error. + {:suspended, _, _, suspended_state} -> + raise RuntimeError, value: Suspend.boundary_message(), state: suspended_state + {:error, msg} -> raise RuntimeError, value: "error loading module '#{modname}' from file '#{file_path}':\n#{msg}" @@ -849,7 +878,11 @@ defmodule Lua.VM.Stdlib do {Value.to_string(value), state} tostring_fn -> - {results, state} = Executor.call_function(tostring_fn, [value], state) + # A __tostring metamethod that suspends cannot be resumed: + # tostring's own frame is not capturable. + {results, state} = + Suspend.guard(fn -> Executor.call_function(tostring_fn, [value], state) end) + {List.first(results) || "nil", state} end diff --git a/lib/lua/vm/stdlib/string.ex b/lib/lua/vm/stdlib/string.ex index 93bb808..1ef8b97 100644 --- a/lib/lua/vm/stdlib/string.ex +++ b/lib/lua/vm/stdlib/string.ex @@ -37,6 +37,7 @@ defmodule Lua.VM.Stdlib.String do alias Lua.VM.Stdlib.Pattern alias Lua.VM.Stdlib.String.Pack alias Lua.VM.Stdlib.Util + alias Lua.VM.Suspend @impl true def lib_name, do: "string" @@ -1126,7 +1127,9 @@ defmodule Lua.VM.Stdlib.String do match?({:lua_closure, _, _}, repl) or match?({:compiled_closure, _, _}, repl) or match?({:native_func, _}, repl) -> fn args, st -> - {results, st} = Executor.call_function(repl, args, st) + # A replacement function that suspends cannot be resumed: the + # pattern engine's iteration frame is not capturable. + {results, st} = Suspend.guard(fn -> Executor.call_function(repl, args, st) end) result = List.first(results) value = if result == nil or result == false, do: false, else: result {value, st} diff --git a/lib/lua/vm/stdlib/table.ex b/lib/lua/vm/stdlib/table.ex index ab23525..dd70bd7 100644 --- a/lib/lua/vm/stdlib/table.ex +++ b/lib/lua/vm/stdlib/table.ex @@ -34,6 +34,7 @@ defmodule Lua.VM.Stdlib.Table do alias Lua.VM.RuntimeError alias Lua.VM.State alias Lua.VM.Stdlib.Util + alias Lua.VM.Suspend alias Lua.VM.Table # ltablib.c sort rejects arrays whose length reaches INT_MAX (2^31 - 1) @@ -417,7 +418,9 @@ defmodule Lua.VM.Stdlib.Table do end defp invoke_compare(comp, a, b, state) do - {results, state} = Executor.call_function(comp, [a, b], state) + # A comparator that suspends cannot be resumed: the insertion-sort + # frame driving it is not capturable. + {results, state} = Suspend.guard(fn -> Executor.call_function(comp, [a, b], state) end) {!!List.first(results), state} end diff --git a/lib/lua/vm/suspend.ex b/lib/lua/vm/suspend.ex new file mode 100644 index 0000000..451627e --- /dev/null +++ b/lib/lua/vm/suspend.ex @@ -0,0 +1,76 @@ +defmodule Lua.VM.Suspend do + @moduledoc false + + # Internal control-flow signal raised by a `deflua` / Elixir callback that + # returns `{:suspend, values}` (or `{:suspend, values, %Lua{}}`). + # + # Not a Lua error: it is never wrapped into `Lua.RuntimeException` and never + # handed to `pcall` as an error value. It travels from the callback's raise + # site up to the nearest VM call boundary, where the executor/dispatcher + # catch it and attach a resumable machine layer (`layers`), or a boundary + # guard converts it into a `Lua.VM.RuntimeError` when resumption is + # impossible (metamethods, generic-for iterators, sort/gsub callbacks, + # `pcall`/`xpcall`, re-entrant natives). + # + # Fields: + # + # * `:values` — encoded Lua values yielded to the host. + # * `:state` — the `Lua.VM.State` at the suspend point (heap effects + # made before the suspend are kept, like VM error exceptions). + # * `:layers` — `nil` until a catch site attaches the continuation layer + # stack (innermost first). `nil` means "suspended directly at this + # native call"; a non-nil list means a nested machine suspended and + # each enclosing engine boundary pushed its own layer. + alias Lua.VM.RuntimeError + + defexception [:values, :state, :layers] + + @type t :: %__MODULE__{} + + @impl true + def message(%__MODULE__{}), do: "suspend" + + @doc """ + The error message used when a suspend crosses a native call boundary. + """ + @spec boundary_message() :: String.t() + def boundary_message, do: "attempt to suspend across a native call boundary" + + @doc """ + Runs `fun`, converting any `Lua.VM.Suspend` escaping it into a + `Lua.VM.RuntimeError` boundary error. + + Used at native-call paths that cannot resume mid-call (metamethods, + generic-for iterators, sort/gsub callbacks, `load` readers, `require` + loaders, `__tostring`): the machine is parked at a site with no + continuation capture, so a suspend there is a boundary error, not a + resumable suspension. + """ + @spec guard((-> result)) :: result when result: any() + def guard(fun) when is_function(fun, 0) do + fun.() + rescue + e in __MODULE__ -> raise RuntimeError, value: boundary_message(), state: e.state + end + + @doc """ + Raises the boundary error for a suspend that cannot be resumed. + + `fallback_state` is used when the exception carries no state of its own. + `:line`/`:source`/`:call_stack` are passed through to the + `Lua.VM.RuntimeError`; when omitted, the runtime error's `exception/1` + fills them from the process-dictionary source position. + """ + @spec raise_boundary!(t(), keyword()) :: no_return() + def raise_boundary!(%__MODULE__{} = e, opts) do + state = e.state || Keyword.get(opts, :fallback_state) + call_stack = Keyword.get(opts, :call_stack) || (state && state.call_stack) || [] + + raise RuntimeError, + value: boundary_message(), + state: state, + call_stack: call_stack, + line: Keyword.get(opts, :line), + source: Keyword.get(opts, :source) + end +end diff --git a/test/lua/suspend_resume_test.exs b/test/lua/suspend_resume_test.exs new file mode 100644 index 0000000..5d9ad23 --- /dev/null +++ b/test/lua/suspend_resume_test.exs @@ -0,0 +1,481 @@ +defmodule Lua.SuspendResumeTest do + @moduledoc """ + Host-driven suspension and resumption of a Lua evaluation. + + A `deflua` / Elixir callback suspends the running machine by returning + `{:suspend, values}` (or `{:suspend, values, lua}` to thread state). + `Lua.eval!/2` / `Lua.call_function/3` then return `{:suspended, values, + lua}` with a continuation on the `%Lua{}`, and `Lua.resume/2` re-enters + the parked machine — the resume values become the suspended call's + results inside Lua. + + A suspend that cannot be resumed — across a `pcall` boundary, a + metamethod, a `table.sort` comparator, a `string.gsub` replacement + function, a generic-for iterator, or a re-entrant native call — surfaces + as the catchable Lua error `"attempt to suspend across a native call + boundary"`. + """ + use ExUnit.Case, async: true + + defmodule Scheduler do + @moduledoc false + use Lua.API, scope: "sched" + + deflua(yield(value), do: {:suspend, [value]}) + + deflua yield_with_state(value), state do + # Heap effect threaded through the returned %Lua{} before suspending + {:suspend, [value], Lua.set!(state, [:yielded], value)} + end + + deflua run(f), state do + # Re-entrant: calling a Lua closure that suspends from inside a + # deflua is a native call boundary. + Lua.call_function!(state, f, []) + end + end + + @boundary_message "attempt to suspend across a native call boundary" + + defp yielder(lua) do + Lua.set!(lua, [:yield], fn args, lua -> {:suspend, args, lua} end) + end + + describe "eval!/2 suspension" do + test "a suspending callback parks the evaluation instead of raising" do + lua = yielder(Lua.new()) + + assert {:suspended, [1, 2], lua} = Lua.eval!(lua, "return yield(1, 2)") + assert %Lua.VM.Continuation{} = lua.continuation + end + + test "resume values become the suspended call's results" do + lua = yielder(Lua.new()) + {:suspended, [1], lua} = Lua.eval!(lua, "local a = yield(1) return a + 41") + + assert {:ok, [42], lua} = Lua.resume(lua, [1]) + assert lua.continuation == nil + end + + test "suspend values are decoded like results with decode: true" do + lua = yielder(Lua.new()) + + assert {:suspended, [[{"k", "v"}]], _lua} = + Lua.eval!(lua, "return yield({k = 'v'})") + end + + test "suspend values stay encoded with decode: false" do + lua = yielder(Lua.new()) + + assert {:suspended, [%Lua.VM.Display.Table{}], _lua} = + Lua.eval!(lua, "return yield({k = 'v'})", decode: false) + end + + test "a non-encoded suspend value is rejected like a bad return" do + lua = Lua.set!(Lua.new(), [:yield], fn _args, _lua -> {:suspend, [%{not: :encoded}]} end) + + assert_raise Lua.RuntimeException, + ~r/deflua functions must return encoded data/, + fn -> Lua.eval!(lua, "return yield()") end + end + + test "suspend without threading state keeps the entry state" do + lua = Lua.set!(Lua.new(), [:yield], fn args -> {:suspend, args} end) + {:suspended, [], lua} = Lua.eval!(lua, "g = 1 local a = yield() return a") + + assert {:ok, [nil], _lua} = Lua.resume(lua, []) + end + end + + describe "result placements" do + test "multi-return assignment" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "local a, b = yield() return a * 10 + b") + + assert {:ok, [12], _} = Lua.resume(lua, [1, 2]) + end + + test "tail return" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "return yield(9)") + + assert {:ok, [7], _} = Lua.resume(lua, [7]) + end + + test "call as argument" do + lua = yielder(Lua.new()) + + {:suspended, _, lua} = + Lua.eval!(lua, "local function add(x, y) return x + y end return add(yield(), 100)") + + assert {:ok, [105], _} = Lua.resume(lua, [5]) + end + + test "vararg spread" do + lua = yielder(Lua.new()) + + {:suspended, _, lua} = + Lua.eval!(lua, "local function f(...) local t = {...} return #t end return f(yield(1))") + + assert {:ok, [3], _} = Lua.resume(lua, [1, 2, 3]) + end + + test "discarded results" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "yield() return 42") + + assert {:ok, [42], _} = Lua.resume(lua, [1, 2]) + end + + test "extra resume values are dropped, missing ones are nil" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "local a = yield() return a") + assert {:ok, [1], _} = Lua.resume(lua, [1, 2, 3]) + + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "local a, b = yield() return b == nil") + assert {:ok, [true], _} = Lua.resume(lua, [1]) + end + + test "suspension works for pre-compiled chunks" do + lua = yielder(Lua.new()) + {chunk, lua} = Lua.load_chunk!(lua, "return yield(3) + 1") + + assert {:suspended, [3], lua} = Lua.eval!(lua, chunk) + assert {:ok, [42], _} = Lua.resume(lua, [41]) + end + end + + describe "resume/2" do + test "chained suspends resume one at a time" do + lua = yielder(Lua.new()) + + assert {:suspended, [1], lua} = Lua.eval!(lua, "local a = yield(1) return a + yield(a)") + assert {:suspended, [10], lua} = Lua.resume(lua, [10]) + assert {:ok, [110], _} = Lua.resume(lua, [100]) + end + + test "suspend inside a numeric-for loop body" do + lua = yielder(Lua.new()) + + assert {:suspended, [1], lua} = + Lua.eval!(lua, "local s = 0 for i = 1, 3 do s = s + yield(i) end return s") + + assert {:suspended, [2], lua} = Lua.resume(lua, [10]) + assert {:suspended, [3], lua} = Lua.resume(lua, [10]) + assert {:ok, [30], _} = Lua.resume(lua, [10]) + end + + test "suspend inside a generic-for loop body" do + lua = yielder(Lua.new()) + + code = """ + local function iter() + local n = 0 + return function() n = n + 1 if n <= 2 then return n end end + end + local s = 0 + for i in iter() do s = s + yield(i) end + return s + """ + + assert {:suspended, [1], lua} = Lua.eval!(lua, code) + assert {:suspended, [2], lua} = Lua.resume(lua, [10]) + assert {:ok, [12], _} = Lua.resume(lua, [2]) + end + + test "heap effects made before the suspend are kept" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "g = 99 local a = yield() return a + g") + + assert {:ok, [100], _} = Lua.resume(lua, [1]) + end + + test "heap effects made before a post-resume error are kept" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "g = 5 local a = yield() return a + missing_global") + + assert {:error, exception, lua} = Lua.resume(lua, [1]) + assert %Lua.RuntimeException{} = exception + assert lua.continuation == nil + + # The global write survived the error, matching pcall semantics + assert {[5], _} = Lua.eval!(lua, "return g") + end + + test "resume! returns the final values or suspends again" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "local a = yield(1) return a * 2") + + assert {[42], _} = Lua.resume!(lua, [21]) + end + + test "resume! reraises a post-resume Lua error" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "local a = yield() return a + nil") + + assert_raise Lua.RuntimeException, ~r/attempt to perform arithmetic/, fn -> + Lua.resume!(lua, [1]) + end + end + + test "resuming without a continuation raises ArgumentError" do + assert_raise ArgumentError, ~r/no suspended continuation/, fn -> + Lua.resume(Lua.new(), []) + end + end + + test "an unrelated eval between suspend and resume preserves the continuation" do + lua = yielder(Lua.new()) + {:suspended, _, lua} = Lua.eval!(lua, "local a = yield(1) return a + 1") + + assert {[99], lua} = Lua.eval!(lua, "return 99") + assert {:ok, [42], _} = Lua.resume(lua, [41]) + end + + test ":max_instructions spans the whole suspend/resume cycle" do + lua = [max_instructions: 50] |> Lua.new() |> yielder() + + assert {:suspended, [1], lua} = + Lua.eval!( + lua, + "local a = yield(1) local s = 0 for i = 1, 1000 do s = s + i end return s" + ) + + # The tally carried in the continuation keeps counting across the + # suspend point: the resumed loop trips the same budget. + assert {:error, exception, _} = Lua.resume(lua, [1]) + assert Exception.message(exception) =~ "instruction budget exceeded" + end + end + + describe "nested functions (dispatcher path)" do + test "suspend inside a nested Lua function resumes through both engines" do + lua = yielder(Lua.new()) + + {:suspended, [5], lua} = + Lua.eval!(lua, "local function f(x) return yield(x) + 1 end return f(5) * 2") + + assert {:ok, [22], _} = Lua.resume(lua, [10]) + end + + test "deep call stack" do + lua = yielder(Lua.new()) + + code = """ + local function g(x) return yield(x) end + local function f(x) return g(x) + 1 end + return f(1) + """ + + {:suspended, _, lua} = Lua.eval!(lua, code) + assert {:ok, [11], _} = Lua.resume(lua, [10]) + end + + test "suspend inside a closure over an upvalue" do + lua = yielder(Lua.new()) + + code = """ + local function make() + local n = 0 + return function(v) + n = n + v + yield(n) + return n + end + end + local f = make() + f(1) + return f(2) + """ + + assert {:suspended, [1], lua} = Lua.eval!(lua, code) + assert {:suspended, [3], lua} = Lua.resume(lua, []) + assert {:ok, [3], _} = Lua.resume(lua, []) + end + end + + describe "deflua" do + test "deflua can suspend without threading state" do + lua = Lua.load_api(Lua.new(), Scheduler) + + assert {:suspended, [7], lua} = Lua.eval!(lua, "return sched.yield(7) + 1") + assert {:ok, [42], _} = Lua.resume(lua, [41]) + end + + test "deflua can thread state across the suspend" do + lua = Lua.load_api(Lua.new(), Scheduler) + + assert {:suspended, [9], lua} = Lua.eval!(lua, "local a = sched.yield_with_state(9) return a") + assert {[9], _} = Lua.eval!(lua, "return yielded") + assert {:ok, [9], _} = Lua.resume(lua, [9]) + end + + test "a deflua that re-enters Lua and gets a suspend is a boundary error" do + lua = Lua.load_api(Lua.new(), Scheduler) + {[f], lua} = Lua.eval!(lua, "return function() return sched.yield(1) end", decode: false) + + assert {:error, exception, _lua} = Lua.call_function(lua, [:sched, :run], [f]) + assert %Lua.RuntimeException{} = exception + assert Exception.message(exception) =~ @boundary_message + end + end + + describe "call_function/3" do + test "calling a Lua closure that suspends returns {:suspended, ...}" do + lua = yielder(Lua.new()) + {[f], lua} = Lua.eval!(lua, "return function(x) return yield(x) * 2 end", decode: false) + + assert {:suspended, [10], lua} = Lua.call_function(lua, f, [10]) + assert {:ok, [42], _} = Lua.resume(lua, [21]) + end + + test "calling a suspending native directly resumes with the resume values as results" do + lua = yielder(Lua.new()) + + assert {:suspended, [5], lua} = Lua.call_function(lua, [:yield], [5]) + assert {:ok, [77], _} = Lua.resume(lua, [77]) + end + + test "call_function! mirrors the suspended return" do + lua = yielder(Lua.new()) + {[f], lua} = Lua.eval!(lua, "return function(x) return yield(x) * 2 end", decode: false) + + assert {:suspended, [10], lua} = Lua.call_function!(lua, f, [10]) + assert {[42], _} = Lua.resume!(lua, [21]) + end + end + + describe "boundaries: a suspend that cannot resume is a catchable Lua error" do + test "pcall around a suspending closure" do + lua = yielder(Lua.new()) + + assert {[false, @boundary_message], _} = + Lua.eval!(lua, "return pcall(function() return yield(1) end)") + end + + test "pcall with the suspending native as the protected function" do + lua = yielder(Lua.new()) + + assert {[false, @boundary_message], _} = Lua.eval!(lua, "return pcall(yield, 42)") + end + + test "xpcall reports the boundary error to the message handler" do + lua = yielder(Lua.new()) + + assert {[false, "handled: " <> @boundary_message], _} = + Lua.eval!( + lua, + "return xpcall(function() return yield(1) end, function(e) return 'handled: ' .. e end)" + ) + end + + test "__index metamethod" do + lua = yielder(Lua.new()) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() local t = setmetatable({}, {__index = function(_, k) return yield(k) end}) return t.foo end)" + ) + + assert msg =~ @boundary_message + end + + test "__newindex metamethod" do + lua = yielder(Lua.new()) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() local t = setmetatable({}, {__newindex = function(_, k, v) yield(v) end}) t.foo = 1 end)" + ) + + assert msg =~ @boundary_message + end + + test "arithmetic metamethod" do + lua = yielder(Lua.new()) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() local t = setmetatable({}, {__add = function() return yield(1) end}) return t + 1 end)" + ) + + assert msg =~ @boundary_message + end + + test "__call metamethod" do + lua = yielder(Lua.new()) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() local t = setmetatable({}, {__call = function(_, x) return yield(x) end}) return t(1) end)" + ) + + assert msg =~ @boundary_message + end + + test "__tostring metamethod" do + lua = Lua.set!(Lua.new(), [:yield], fn args, lua -> {:suspend, args, lua} end) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() local t = setmetatable({}, {__tostring = function() return yield() end}) return tostring(t) end)" + ) + + assert msg =~ @boundary_message + end + + test "table.sort comparator" do + lua = yielder(Lua.new()) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() table.sort({3, 1, 2}, function(a, b) yield(a) return a < b end) end)" + ) + + assert msg =~ @boundary_message + end + + test "string.gsub replacement function" do + lua = yielder(Lua.new()) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() return string.gsub('abc', '.', function(s) yield(s) return s end) end)" + ) + + assert msg =~ @boundary_message + end + + test "generic-for iterator" do + lua = yielder(Lua.new()) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() local function iter() return function() yield() return nil end end for _ in iter() do end end)" + ) + + assert msg =~ @boundary_message + end + + test "load reader function" do + lua = yielder(Lua.new(sandboxed: [])) + + assert {[false, msg], _} = + Lua.eval!( + lua, + "return pcall(function() return load(function() yield() return 'return 1' end) end)" + ) + + assert msg =~ @boundary_message + end + end +end From 022946b7b8636303c446382df5b658005ec96472 Mon Sep 17 00:00:00 2001 From: dompdv <1734209+dompdv@users.noreply.github.com.> Date: Fri, 23 Oct 2026 15:21:35 +0200 Subject: [PATCH 5/5] chore(A51): mark plan as review PR: https://github.com/tv-labs/lua/pull/434 Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- .agents/plans/A51-deflua-suspend-resume.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/plans/A51-deflua-suspend-resume.md b/.agents/plans/A51-deflua-suspend-resume.md index a7c6138..e24015c 100644 --- a/.agents/plans/A51-deflua-suspend-resume.md +++ b/.agents/plans/A51-deflua-suspend-resume.md @@ -2,10 +2,10 @@ id: A51 title: "Suspend/resume: deflua returns {:suspend, values}, Lua.eval!/call_function surface {:suspended, values, lua}, Lua.resume/2 continues" issue: null -pr: null +pr: 434 branch: feat/deflua-suspend-resume base: main -status: in-progress +status: review direction: A ---