From e58710c87da9e95776b7c5f23bfb5e8153c399d6 Mon Sep 17 00:00:00 2001 From: David Bernheisel Date: Mon, 19 Oct 2026 12:45:37 -0400 Subject: [PATCH 1/3] feat(vm): add a generational garbage collector behind Lua.gc/2 The VM never removed tables, closure cells or userdata from its state. A long-lived state that kept calling a retained callback grew without bound. Lua.gc/2 runs a mark-and-sweep between evaluations. Minor collections trace only objects created since the last collection; a write barrier in State.put_table/3 records older tables written in between. :auto paces collections by allocation and runs a major collection when the heap doubles. Collection refuses to run while Lua code is running, tracked by a new entry_depth counter. string.gmatch now keeps its cursor in an upvalue cell instead of private storage, so the collector can free it. The Bootstrap reset/0 tests move to a synchronous module because they assert that global :persistent_term state is empty. --- lib/lua.ex | 55 +++- lib/lua/vm.ex | 4 +- lib/lua/vm/dispatcher.ex | 4 +- lib/lua/vm/executor.ex | 6 +- lib/lua/vm/gc.ex | 245 ++++++++++++++++++ lib/lua/vm/state.ex | 63 ++++- lib/lua/vm/stdlib.ex | 2 +- lib/lua/vm/stdlib/string.ex | 7 +- lib/lua/vm/stdlib/table.ex | 2 +- test/lua/vm/bootstrap_reset_test.exs | 87 +++++++ test/lua/vm/bootstrap_test.exs | 73 ------ test/lua/vm/gc_test.exs | 363 +++++++++++++++++++++++++++ 12 files changed, 818 insertions(+), 93 deletions(-) create mode 100644 lib/lua/vm/gc.ex create mode 100644 test/lua/vm/bootstrap_reset_test.exs create mode 100644 test/lua/vm/gc_test.exs diff --git a/lib/lua.ex b/lib/lua.ex index e055366..8695765 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -11,6 +11,7 @@ defmodule Lua do alias Lua.VM.Bootstrap alias Lua.VM.Display alias Lua.VM.Executor + alias Lua.VM.GC alias Lua.VM.InternalError alias Lua.VM.RuntimeError alias Lua.VM.State @@ -849,8 +850,8 @@ defmodule Lua do end defp do_call_function({:native_func, fun}, args, state) do - {results, new_state} = fun.(args, state) - {:ok, List.wrap(results), new_state} + {results, new_state} = fun.(args, State.enter_host(state)) + {:ok, List.wrap(results), State.exit_host(new_state, state)} rescue e -> {:error, e, recover_state(e, state)} end @@ -866,9 +867,9 @@ defmodule Lua do end) {results, _regs, new_state} = - Executor.execute(proto.instructions, callee_regs, upvalues, proto, state) + Executor.execute(proto.instructions, callee_regs, upvalues, proto, State.enter_host(state)) - {:ok, results, new_state} + {:ok, results, State.exit_host(new_state, state)} rescue e -> {:error, e, recover_state(e, state)} end @@ -876,8 +877,8 @@ defmodule Lua do defp do_call_function({:compiled_closure, _, _} = closure, args, state) do # Compiled callees route through the dispatcher; same observable # contract as the interpreter branch above. - {results, new_state} = Executor.call_function(closure, args, state) - {:ok, results, new_state} + {results, new_state} = Executor.call_function(closure, args, State.enter_host(state)) + {:ok, results, State.exit_host(new_state, state)} rescue e -> {:error, e, recover_state(e, state)} end @@ -1184,6 +1185,48 @@ defmodule Lua do %{lua | state: State.delete_private(state, key)} end + @doc """ + Frees tables, closure cells and userdata that Lua can no longer reach. + + The VM has no automatic collector, so a long-lived state that keeps + running code grows without bound unless this is called between + evaluations. Calling it after every evaluation is cheap: by default it + does nothing until enough new objects have been allocated, and then + usually traces only the objects created since the previous collection. + See `Lua.VM.GC` for the pacing rules. + + Anything reachable from globals, metatables, private storage, Elixir + callbacks or `:keep` survives. A table or function reference held only by Elixir code is + **not** a root and may dangle afterwards — pass it in `:keep`, or store + it in a global first. + + Must be called between evaluations, not from inside an Elixir callback + that Lua is running. + + ## Options + + * `:keep` - (default `[]`) extra values to treat as roots. + * `:mode` - (default `:auto`) `:auto` paces itself; `:minor` collects + only the objects created since the last collection; `:major` traces + the whole heap. + + iex> lua = Lua.new() + iex> {_, lua} = Lua.eval!(lua, "for i = 1, 100 do local t = {i} end") + iex> lua = Lua.gc(lua, mode: :major) + iex> {[42], _} = Lua.eval!(lua, "return 42") + """ + @spec gc(t(), keyword()) :: t() + def gc(%__MODULE__{state: state} = lua, opts \\ []) do + opts = Keyword.validate!(opts, keep: [], mode: :auto) + mode = Keyword.fetch!(opts, :mode) + + if mode not in [:auto, :minor, :major] do + raise ArgumentError, ":mode must be :auto, :minor or :major, got: #{inspect(mode)}" + end + + %{lua | state: GC.collect(state, Keyword.fetch!(opts, :keep), mode)} + end + # Note: These functions are called from load_api and always go through set!/3's # arity-2 clause, which wraps the state as %Lua{} before calling. So `lua` is # already a %Lua{} struct — do NOT call wrap() again. diff --git a/lib/lua/vm.ex b/lib/lua/vm.ex index 27467d4..6c4355f 100644 --- a/lib/lua/vm.ex +++ b/lib/lua/vm.ex @@ -51,8 +51,8 @@ defmodule Lua.VM do # Execute the prototype instructions {results, _final_regs, final_state} = - Executor.execute(proto.instructions, registers, [], proto, state) + Executor.execute(proto.instructions, registers, [], proto, State.enter_host(state)) - {:ok, results, final_state} + {:ok, results, State.exit_host(final_state, state)} end end diff --git a/lib/lua/vm/dispatcher.ex b/lib/lua/vm/dispatcher.ex index e04d96b..9559ab8 100644 --- a/lib/lua/vm/dispatcher.ex +++ b/lib/lua/vm/dispatcher.ex @@ -398,7 +398,7 @@ defmodule Lua.VM.Dispatcher do state = case :erlang.map_get(:metatable, table) do nil -> - %{state | tables: :maps.put(id, Table.put(table, name, value), state.tables)} + State.put_table(state, id, Table.put(table, name, value)) _ -> Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) @@ -1504,7 +1504,7 @@ defmodule Lua.VM.Dispatcher do state = case :erlang.map_get(:metatable, table) do nil -> - %{state | tables: :maps.put(id, Table.put(table, name, value), state.tables)} + State.put_table(state, id, Table.put(table, name, value)) _ -> Executor.dispatcher_set_field(table_val, name, value, sync(state, cs, cd), proto, name_hint) diff --git a/lib/lua/vm/executor.ex b/lib/lua/vm/executor.ex index ccad361..4cbaaa8 100644 --- a/lib/lua/vm/executor.ex +++ b/lib/lua/vm/executor.ex @@ -3526,19 +3526,19 @@ defmodule Lua.VM.Executor do case table.metatable do nil -> updated = Table.put(table, key, value) - %{state | tables: Map.put(state.tables, id, updated)} + State.put_table(state, id, updated) {:tref, mt_id} -> if Table.has_key?(table, key) do updated = Table.put(table, key, value) - %{state | tables: Map.put(state.tables, id, updated)} + State.put_table(state, id, updated) else mt = Map.fetch!(state.tables, mt_id) case Map.get(mt.data, "__newindex") do nil -> updated = Table.put(table, key, value) - %{state | tables: Map.put(state.tables, id, updated)} + State.put_table(state, id, updated) {:tref, _} = newindex_table -> table_newindex(newindex_table, key, value, state, depth + 1) diff --git a/lib/lua/vm/gc.ex b/lib/lua/vm/gc.ex new file mode 100644 index 0000000..38fd76f --- /dev/null +++ b/lib/lua/vm/gc.ex @@ -0,0 +1,245 @@ +defmodule Lua.VM.GC do + @moduledoc """ + Generational mark-and-sweep over the VM heap. + + The heap is the three id-keyed stores on `Lua.VM.State`: `tables`, + `upvalue_cells` and `userdata`. Nothing else ever removes entries from + them, so without a sweep every temporary table and every closure cell a + script creates is retained for the life of the state. + + ## Generations + + Table ids are allocated in increasing order, so the tables created since + the last collection are exactly those with `id >= state.gc_young_from`. + A minor collection traces only those young tables, and the upvalue cells + created since the last collection, and frees the unreachable ones. Its + roots are: + + * the usual roots (below), traced only as far as young objects; + * every older table written since the last collection, which + `Lua.VM.State.put_table/3` records in `gc_remembered`; + * the value of every older upvalue cell. Cells are scanned rather than + barriered: their ids are unordered and their values are shallow. + + Anything older is assumed live. An old table, cell or userdata that has + become garbage is only reclaimed by a major collection, which traces the + whole heap. A minor collection's cost is proportional to the young + objects, the remembered tables' entries and the number of upvalue cells. + + ## Pacing + + `:auto` does nothing until 1,000 tables, cells and userdata have been + allocated since the last collection, then runs a minor collection, then + a major one once the heap has doubled since the last major — the same + 200% pause Lua uses by default. The first `:auto` collection on a state + is always major. + + ## Safe points + + Live registers are threaded through the executor and dispatcher as loop + parameters, not stored in `%State{}`. A sweep while Lua frames are active + would not see them and would free live tables. `collect/3` therefore only + runs when no host entry point is in progress (`entry_depth` is zero) — + i.e. between top-level evaluations — and raises otherwise, including from + an Elixir callback that Lua is running. This is also why in-Lua + `collectgarbage("collect")` stays a no-op. + + ## Roots + + `_G`, the type metatables, every private value, any open upvalue cells, + and the `keep` values the caller passes. A userdata payload is traced + when its userdata is reached. Elixir functions are traced through their + captured environment, so a native function or `Lua.set!/3` callback keeps + the tables it captured alive. A captured `%Lua{}` or `%Lua.VM.State{}` is + a stale snapshot and is not traced. References held only in Elixir terms + outside the state are not roots: after a sweep they may dangle. + """ + + alias Lua.VM.State + alias Lua.VM.Table + + @minor_after 1_000 + + @type mode :: :auto | :minor | :major + + @doc """ + Frees the heap objects unreachable from the roots and `keep`. + """ + @spec collect(State.t(), [term()], mode()) :: State.t() + def collect(%State{entry_depth: depth}, _keep, _mode) when depth > 0 do + raise ArgumentError, "Lua.gc/2 must not be called while Lua code is running" + end + + def collect(%State{gc: %{major_at: 0}} = state, keep, :auto), do: major(state, keep) + + def collect(%State{} = state, keep, :auto) do + if young_count(state) < @minor_after do + state + else + state |> minor(keep) |> maybe_major(keep) + end + end + + def collect(%State{} = state, keep, :minor), do: minor(state, keep) + def collect(%State{} = state, keep, :major), do: major(state, keep) + + defp young_count(%State{gc: gc} = state) do + state.table_next_id - state.gc_young_from + + (map_size(state.upvalue_cells) - map_size(gc.old_cells)) + + (state.userdata_next_id - gc.userdata_from) + end + + defp heap_size(state), do: map_size(state.tables) + map_size(state.upvalue_cells) + map_size(state.userdata) + + defp maybe_major(state, keep) do + if heap_size(state) >= state.gc.major_at, do: major(state, keep), else: state + end + + defp minor(%State{gc: gc} = state, keep) do + ctx = context(state, state.gc_young_from, gc.old_cells) + + remembered = + Enum.reduce(state.gc_remembered, [], fn {id, true}, acc -> + table_children(Map.fetch!(state.tables, id), acc) + end) + + old_cell_values = for {ref, _} <- gc.old_cells, do: Map.fetch!(state.upvalue_cells, ref) + + {tables, cells, _userdata} = + drain([roots(state, keep), remembered, old_cell_values], ctx, {%{}, %{}, %{}}) + + dead_tables = + for id <- state.gc_young_from..(state.table_next_id - 1)//1, not is_map_key(tables, id), do: id + + dead_cells = + for {ref, _} <- state.upvalue_cells, + not is_map_key(gc.old_cells, ref), + not is_map_key(cells, ref), + do: ref + + %{ + state + | tables: Map.drop(state.tables, dead_tables), + upvalue_cells: Map.drop(state.upvalue_cells, dead_cells), + gc_young_from: state.table_next_id, + gc_remembered: %{}, + gc: %{gc | old_cells: Map.merge(gc.old_cells, cells), userdata_from: state.userdata_next_id} + } + end + + defp major(state, keep) do + {tables, cells, userdata} = drain([roots(state, keep)], context(state, 0, %{}), {%{}, %{}, %{}}) + live = map_size(tables) + map_size(cells) + map_size(userdata) + + %{ + state + | tables: tables, + upvalue_cells: cells, + userdata: userdata, + gc_young_from: state.table_next_id, + gc_remembered: %{}, + gc: %{ + old_cells: cells, + userdata_from: state.userdata_next_id, + major_at: max(2 * live, live + @minor_after) + } + } + end + + defp context(state, young_from, old_cells) do + %{ + tables: state.tables, + cells: state.upvalue_cells, + userdata: state.userdata, + young_from: young_from, + old_cells: old_cells + } + end + + defp roots(state, keep) do + [ + state.g_ref, + Map.values(state.metatables), + Map.values(state.private), + Map.values(state.open_upvalues), + keep + ] + end + + # Drain does not enter old objects (`id < young_from` or in `old_cells`). + # The remembered set and the old-cell scan supply their young references. + defp drain([], _ctx, marks), do: marks + + defp drain([{:tref, id} | rest], ctx, {tables, cells, userdata} = marks) do + cond do + id < ctx.young_from or is_map_key(tables, id) -> + drain(rest, ctx, marks) + + is_map_key(ctx.tables, id) -> + table = Map.fetch!(ctx.tables, id) + drain(table_children(table, rest), ctx, {Map.put(tables, id, table), cells, userdata}) + + true -> + drain(rest, ctx, marks) + end + end + + defp drain([{:udref, id} | rest], ctx, {tables, cells, userdata} = marks) do + if is_map_key(userdata, id) or not is_map_key(ctx.userdata, id) do + drain(rest, ctx, marks) + else + payload = Map.fetch!(ctx.userdata, id) + drain([payload | rest], ctx, {tables, cells, Map.put(userdata, id, payload)}) + end + end + + defp drain([{tag, _proto, upvalues} | rest], ctx, marks) when tag in [:lua_closure, :compiled_closure] do + drain([upvalues | rest], ctx, marks) + end + + defp drain([ref | rest], ctx, {tables, cells, userdata} = marks) when is_reference(ref) do + cond do + is_map_key(cells, ref) or is_map_key(ctx.old_cells, ref) -> + drain(rest, ctx, marks) + + is_map_key(ctx.cells, ref) -> + value = Map.fetch!(ctx.cells, ref) + drain([value | rest], ctx, {tables, Map.put(cells, ref, value), userdata}) + + true -> + drain(rest, ctx, marks) + end + end + + defp drain([fun | rest], ctx, marks) when is_function(fun) do + {:env, env} = Function.info(fun, :env) + drain([env | rest], ctx, marks) + end + + defp drain([%{__struct__: struct} | rest], ctx, marks) when struct in [Lua, State] do + drain(rest, ctx, marks) + end + + defp drain([%{__struct__: _} = struct | rest], ctx, marks) do + drain([Map.from_struct(struct) | rest], ctx, marks) + end + + defp drain([map | rest], ctx, marks) when is_map(map) do + drain(:maps.fold(fn k, v, acc -> [k, v | acc] end, rest, map), ctx, marks) + end + + defp drain([[] | rest], ctx, marks), do: drain(rest, ctx, marks) + defp drain([[head | tail] | rest], ctx, marks), do: drain([head, tail | rest], ctx, marks) + + defp drain([tuple | rest], ctx, marks) when is_tuple(tuple) do + drain([Tuple.to_list(tuple) | rest], ctx, marks) + end + + defp drain([_leaf | rest], ctx, marks), do: drain(rest, ctx, marks) + + defp table_children(%Table{arr: :undefined} = table, rest), do: [table.metatable, table.data | rest] + + defp table_children(%Table{arr: arr} = table, rest) do + [table.metatable, table.data | :array.sparse_foldl(fn _i, v, acc -> [v | acc] end, rest, arr)] + end +end diff --git a/lib/lua/vm/state.ex b/lib/lua/vm/state.ex index da5e62e..2c45001 100644 --- a/lib/lua/vm/state.ex +++ b/lib/lua/vm/state.ex @@ -23,6 +23,9 @@ defmodule Lua.VM.State do # means no limit. See `check_call_depth!/1`. call_depth: 0, max_call_depth: :infinity, + # `Lua.VM.GC` refuses to collect while host entry points are active. + # `call_stack` cannot replace this count: some native callbacks see it empty. + entry_depth: 0, # Ceiling for any single string the VM will build (`..`, # `string.rep`, `load` reader chunks). Defaults to the practical # bound in `Lua.VM.Limits`. Embedders running the VM under a @@ -59,6 +62,10 @@ defmodule Lua.VM.State do # require globals to be a real Lua table so `_ENV` reassignment # can redirect global access. g_ref: nil, + # The write barrier reads `gc_young_from` and `gc_remembered`; only a collection reads `gc`. + gc_young_from: 0, + gc_remembered: %{}, + gc: %{old_cells: %{}, userdata_from: 0, major_at: 0}, # Memoizes parsed `string.format` templates (format string -> # compiled segment list) so a format string reused across calls — # the common `for ... string.format(FMT, ...)` loop shape — is @@ -71,6 +78,7 @@ defmodule Lua.VM.State do call_stack: list(), call_depth: non_neg_integer(), max_call_depth: pos_integer() | :infinity, + entry_depth: non_neg_integer(), max_string_bytes: pos_integer() | :infinity, max_instructions: pos_integer() | :infinity, instruction_count: non_neg_integer(), @@ -83,6 +91,13 @@ defmodule Lua.VM.State do private: map(), multi_return_count: non_neg_integer(), g_ref: nil | {:tref, non_neg_integer()}, + gc_young_from: non_neg_integer(), + gc_remembered: %{optional(non_neg_integer()) => true}, + gc: %{ + old_cells: %{optional(reference()) => term()}, + userdata_from: non_neg_integer(), + major_at: non_neg_integer() + }, format_cache: %{optional(binary()) => list()} } @@ -170,9 +185,10 @@ defmodule Lua.VM.State do | kept from `raised` (heap) | restored from `entry` (control) | |----------------------------------------|---------------------------------| - | `tables`, `table_next_id` | `call_stack`, `call_depth` | + | `tables`, `table_next_id` | `call_stack`, `call_depth`, `entry_depth` | | `userdata`, `userdata_next_id` | `open_upvalues` | | `metatables`, `upvalue_cells`, `private` | `multi_return_count` | + | `gc_remembered` | | The instruction tally `instruction_count` is also carried forward (monotonic max), not reset to the entry value: the work a protected call burned must still count @@ -200,6 +216,7 @@ defmodule Lua.VM.State do metatables: raised.metatables, upvalue_cells: raised.upvalue_cells, private: raised.private, + gc_remembered: raised.gc_remembered, instruction_count: max(entry.instruction_count, raised.instruction_count) } end @@ -293,7 +310,49 @@ defmodule Lua.VM.State do """ @spec update_table(t(), {:tref, non_neg_integer()}, (Table.t() -> Table.t())) :: t() def update_table(state, {:tref, id}, fun) do - %{state | tables: Map.update!(state.tables, id, fun)} + put_table(state, id, fun.(Map.fetch!(state.tables, id))) + end + + @doc """ + Marks `state` as running Lua code for a host entry point. + + `Lua.VM.GC` refuses to collect while any entry is in progress. Pair each + call with `exit_host/2` on the success path. The error path needs no + pair: `unwind_to/2` restores `entry_depth` from the pre-call state. + """ + @spec enter_host(t()) :: t() + def enter_host(%__MODULE__{entry_depth: depth} = state), do: %{state | entry_depth: depth + 1} + + @doc """ + Restores the `entry_depth` of `entry` onto `state` after a host entry point returns. + """ + @spec exit_host(t(), t()) :: t() + def exit_host(%__MODULE__{} = state, %__MODULE__{entry_depth: depth}), do: %{state | entry_depth: depth} + + @doc """ + Stores `table` under `id`, recording the write for `Lua.VM.GC`. + + Every write to an existing table must go through here (or + `update_table/3`): a minor collection traces young objects only from + the tables this barrier remembers, so a write that bypasses it can let + a live young table be freed. Writes to young tables, and repeat writes + to an already remembered one, resolve in a function-head guard. + """ + @spec put_table(t(), non_neg_integer(), Table.t()) :: t() + def put_table(%__MODULE__{gc_young_from: young_from} = state, id, table) when id >= young_from do + %{state | tables: :maps.put(id, table, state.tables)} + end + + def put_table(%__MODULE__{gc_remembered: remembered} = state, id, table) when is_map_key(remembered, id) do + %{state | tables: :maps.put(id, table, state.tables)} + end + + def put_table(%__MODULE__{} = state, id, table) do + %{ + state + | tables: :maps.put(id, table, state.tables), + gc_remembered: :maps.put(id, true, state.gc_remembered) + } end @doc """ diff --git a/lib/lua/vm/stdlib.ex b/lib/lua/vm/stdlib.ex index 6967692..ec42510 100644 --- a/lib/lua/vm/stdlib.ex +++ b/lib/lua/vm/stdlib.ex @@ -362,7 +362,7 @@ defmodule Lua.VM.Stdlib do {table, state} else flushed = Table.flush_order(table) - {flushed, %{state | tables: Map.put(state.tables, id, flushed)}} + {flushed, State.put_table(state, id, flushed)} end case Table.next_entry(table, key) do diff --git a/lib/lua/vm/stdlib/string.ex b/lib/lua/vm/stdlib/string.ex index 93bb808..354eb8a 100644 --- a/lib/lua/vm/stdlib/string.ex +++ b/lib/lua/vm/stdlib/string.ex @@ -1075,20 +1075,21 @@ defmodule Lua.VM.Stdlib.String do # string.gmatch(s, pattern) - returns iterator function defp string_gmatch([s, pattern | _], state) when is_binary(s) and is_binary(pattern) do # Pre-compute all matches, return an iterator that yields them one by one + # The collector frees the cursor cell together with the iterator. matches = Pattern.gmatch(s, pattern) idx_ref = make_ref() - state = %{state | private: Map.put(state.private, idx_ref, {matches, 0})} + state = %{state | upvalue_cells: Map.put(state.upvalue_cells, idx_ref, {matches, 0})} iter_func = {:native_func, fn _args, st -> - {match_list, current_idx} = Map.get(st.private, idx_ref) + {match_list, current_idx} = Map.fetch!(st.upvalue_cells, idx_ref) if current_idx >= length(match_list) do {[nil], st} else result = Enum.at(match_list, current_idx) - st = %{st | private: Map.put(st.private, idx_ref, {match_list, current_idx + 1})} + st = %{st | upvalue_cells: Map.put(st.upvalue_cells, idx_ref, {match_list, current_idx + 1})} {result, st} end end} diff --git a/lib/lua/vm/stdlib/table.ex b/lib/lua/vm/stdlib/table.ex index ab23525..7a4299a 100644 --- a/lib/lua/vm/stdlib/table.ex +++ b/lib/lua/vm/stdlib/table.ex @@ -348,7 +348,7 @@ defmodule Lua.VM.Stdlib.Table do # the hash portion or any other key. updated = Table.replace_sequence(table, sorted) - {[], %{state | tables: Map.put(state.tables, id, updated)}} + {[], State.put_table(state, id, updated)} end # Metatable-backed path: read via __index and write back via __newindex diff --git a/test/lua/vm/bootstrap_reset_test.exs b/test/lua/vm/bootstrap_reset_test.exs new file mode 100644 index 0000000..fcf0334 --- /dev/null +++ b/test/lua/vm/bootstrap_reset_test.exs @@ -0,0 +1,87 @@ +defmodule Lua.VM.BootstrapResetTest do + @moduledoc """ + Pins `Lua.VM.Bootstrap.reset/0`: it erases every memoized boot template, + including ones populated by concurrent cold starts, and `Lua.new/1` keeps + working afterwards. + + Not async: these tests assert the global `:persistent_term` memo is empty, + which a concurrent `Lua.new/1` in any other test would refill. + """ + + use ExUnit.Case, async: false + + alias Lua.VM.Bootstrap + + describe "reset/0" do + test "erases every memoized key so the next fetch rebuilds" do + _warm = Lua.new() + + for key <- Bootstrap.memoized_keys() do + assert :persistent_term.get(key, :missing) != :missing, + "expected #{inspect(key)} to be memoized by Lua.new/1" + end + + assert Bootstrap.reset() == :ok + + for key <- Bootstrap.memoized_keys() do + assert :persistent_term.get(key, :missing) == :missing + end + + assert {[2], _} = Lua.eval!(Lua.new(), "return 1 + 1") + end + + test "erases every memoized key populated by racing cold starts" do + Bootstrap.reset() + + test = self() + + workers = + for _ <- 1..64 do + Task.async(fn -> + send(test, {:ready, self()}) + assert_receive :go + Lua.new() + end) + end + + for %Task{pid: pid} <- workers, do: assert_receive({:ready, ^pid}) + for %Task{pid: pid} <- workers, do: send(pid, :go) + + Task.await_many(workers) + + assert Bootstrap.reset() == :ok + + for key <- Bootstrap.memoized_keys() do + assert :persistent_term.get(key, :missing) == :missing, + "reset/0 left #{inspect(key)} behind after a concurrent cold start" + end + end + + test "does not consult runtime bookkeeping, and erases the obsolete entry" do + # An entry left by a build that tracked the stored keys at runtime. Such + # tracking is a read-modify-write that concurrent cold starts can + # truncate, so a truncated list must not be able to narrow what `reset/0` + # erases — and the entry itself must not survive the reset. + obsolete = {Bootstrap, :keys} + on_exit(fn -> :persistent_term.erase(obsolete) end) + + _warm = Lua.new() + :persistent_term.put(obsolete, []) + + assert Bootstrap.reset() == :ok + + for key <- [obsolete | Bootstrap.memoized_keys()] do + assert :persistent_term.get(key, :missing) == :missing, + "reset/0 left #{inspect(key)} behind" + end + end + + test "Lua.new/1 still works after a reset" do + _warm = Lua.new() + + assert Bootstrap.reset() == :ok + + assert {[2], _} = Lua.eval!(Lua.new(), "return 1 + 1") + end + end +end diff --git a/test/lua/vm/bootstrap_test.exs b/test/lua/vm/bootstrap_test.exs index cd9f5a2..22996b5 100644 --- a/test/lua/vm/bootstrap_test.exs +++ b/test/lua/vm/bootstrap_test.exs @@ -210,77 +210,4 @@ defmodule Lua.VM.BootstrapTest do end end end - - describe "reset/0" do - test "erases every memoized key so the next fetch rebuilds" do - _warm = Lua.new() - - for key <- Bootstrap.memoized_keys() do - assert :persistent_term.get(key, :missing) != :missing, - "expected #{inspect(key)} to be memoized by Lua.new/1" - end - - assert Bootstrap.reset() == :ok - - for key <- Bootstrap.memoized_keys() do - assert :persistent_term.get(key, :missing) == :missing - end - - assert {[2], _} = Lua.eval!(Lua.new(), "return 1 + 1") - end - - test "erases every memoized key populated by racing cold starts" do - Bootstrap.reset() - - test = self() - - workers = - for _ <- 1..64 do - Task.async(fn -> - send(test, {:ready, self()}) - assert_receive :go - Lua.new() - end) - end - - for %Task{pid: pid} <- workers, do: assert_receive({:ready, ^pid}) - for %Task{pid: pid} <- workers, do: send(pid, :go) - - Task.await_many(workers) - - assert Bootstrap.reset() == :ok - - for key <- Bootstrap.memoized_keys() do - assert :persistent_term.get(key, :missing) == :missing, - "reset/0 left #{inspect(key)} behind after a concurrent cold start" - end - end - - test "does not consult runtime bookkeeping, and erases the obsolete entry" do - # An entry left by a build that tracked the stored keys at runtime. Such - # tracking is a read-modify-write that concurrent cold starts can - # truncate, so a truncated list must not be able to narrow what `reset/0` - # erases — and the entry itself must not survive the reset. - obsolete = {Bootstrap, :keys} - on_exit(fn -> :persistent_term.erase(obsolete) end) - - _warm = Lua.new() - :persistent_term.put(obsolete, []) - - assert Bootstrap.reset() == :ok - - for key <- [obsolete | Bootstrap.memoized_keys()] do - assert :persistent_term.get(key, :missing) == :missing, - "reset/0 left #{inspect(key)} behind" - end - end - - test "Lua.new/1 still works after a reset" do - _warm = Lua.new() - - assert Bootstrap.reset() == :ok - - assert {[2], _} = Lua.eval!(Lua.new(), "return 1 + 1") - end - end end diff --git a/test/lua/vm/gc_test.exs b/test/lua/vm/gc_test.exs new file mode 100644 index 0000000..22abf41 --- /dev/null +++ b/test/lua/vm/gc_test.exs @@ -0,0 +1,363 @@ +defmodule Lua.VM.GCTest do + @moduledoc """ + Pins `Lua.gc/2`: what each mode frees and keeps, that the write barrier + covers every VM write path, how `:auto` paces itself, and that collection + is refused while Lua code is running. + """ + + use ExUnit.Case, async: true + + @soak """ + local counter = 0 + function step() + counter = counter + 1 + for i = 1, 100 do + local scratch = {i, tostring(i), {nested = i}} + end + local response = {body = string.rep("x", 8192)} + return counter + end + """ + + defp heap_size(%Lua{state: state}) do + {map_size(state.tables), map_size(state.upvalue_cells)} + end + + # A minor collection can keep garbage, but it must not free a live object. + defp assert_minor_keeps_live(lua) do + minor = Lua.gc(lua, mode: :minor).state + major = Lua.gc(lua, mode: :major).state + + assert Enum.reject(Map.keys(major.tables), &is_map_key(minor.tables, &1)) == [] + assert Enum.reject(Map.keys(major.upvalue_cells), &is_map_key(minor.upvalue_cells, &1)) == [] + end + + describe "major" do + test "retained state returns to baseline after every call" do + {_, lua} = Lua.eval!(Lua.new(), @soak) + lua = Lua.gc(lua, mode: :major) + baseline = heap_size(lua) + + lua = + Enum.reduce(1..1_000, lua, fn n, lua -> + {:ok, [^n], lua} = Lua.call_function(lua, [:step], []) + Lua.gc(lua, mode: :major) + end) + + assert heap_size(lua) == baseline + assert {[1001], _} = Lua.eval!(lua, "return step()") + end + + test "frees old garbage a minor collection keeps" do + {_, lua} = Lua.eval!(Lua.new(), "big = {}") + lua = Lua.gc(lua, mode: :major) + {old, _} = heap_size(lua) + {_, lua} = Lua.eval!(lua, "big = nil") + + assert {^old, _} = heap_size(Lua.gc(lua, mode: :minor)) + assert {freed, _} = heap_size(Lua.gc(lua, mode: :major)) + assert freed == old - 1 + end + end + + test "without gc the same workload grows" do + {_, lua} = Lua.eval!(Lua.new(), @soak) + {before, _} = heap_size(lua) + + lua = + Enum.reduce(1..10, lua, fn _, lua -> + {:ok, _, lua} = Lua.call_function(lua, [:step], []) + lua + end) + + {after_calls, _} = heap_size(lua) + assert after_calls - before >= 1_000 + end + + describe "minor" do + test "frees young garbage and promotes survivors" do + {_, lua} = Lua.eval!(Lua.new(), @soak) + lua = Lua.gc(lua, mode: :major) + baseline = heap_size(lua) + + {:ok, _, lua} = Lua.call_function(lua, [:step], []) + lua = Lua.gc(lua, mode: :minor) + + assert heap_size(lua) == baseline + assert lua.state.gc_young_from == lua.state.table_next_id + assert lua.state.gc_remembered == %{} + end + + # Each case writes a young table into an old table through a different VM write path. + for {name, setup, write, read} <- [ + {"field write", "old = {}", "old.k = {v = 1}", "return old.k.v"}, + {"integer key write", "old = {}", "old[1] = {v = 1}", "return old[1].v"}, + {"computed key write", "old = {}", "local k = 'k' old[k] = {v = 1}", "return old.k.v"}, + {"write inside a function", "old = {}", "local function f() old.k = {v = 1} end f()", "return old.k.v"}, + {"write through a metatable without __newindex", "old = setmetatable({}, {})", "old.k = {v = 1}", + "return old.k.v"}, + {"overwrite through a metatable", "old = setmetatable({k = 0}, {__newindex = error})", "old.k = {v = 1}", + "return old.k.v"}, + {"__newindex table", "old = {} proxy = setmetatable({}, {__newindex = old})", "proxy.k = {v = 1}", + "return old.k.v"}, + {"rawset", "old = {}", "rawset(old, 'k', {v = 1})", "return old.k.v"}, + {"table.insert", "old = {}", "table.insert(old, {v = 1})", "return old[1].v"}, + {"table.insert at position", "old = {0}", "table.insert(old, 1, {v = 1})", "return old[1].v"}, + {"table.move", "old = {}", "table.move({{v = 1}}, 1, 1, 1, old)", "return old[1].v"}, + {"table.sort", "old = {}", + "old[1] = {v = 1} old[2] = {v = 0} table.sort(old, function(a, b) return a.v < b.v end)", "return old[2].v"}, + {"setmetatable", "old = {}", "setmetatable(old, {__index = {v = 1}})", "return old.v"}, + {"global write", "", "g = {v = 1}", "return g.v"}, + {"global write inside a function", "", "local function f() g = {v = 1} end f()", "return g.v"}, + {"closure stored in an old table", "old = {}", "local t = {v = 1} old.f = function() return t end", + "return old.f().v"}, + {"old upvalue cell", "local c function set(x) c = x end function get() return c end", "set({v = 1})", + "return get().v"} + ] do + test "keeps a young table reachable only through #{name}" do + {_, lua} = Lua.eval!(Lua.new(sandboxed: []), unquote(setup)) + lua = Lua.gc(lua, mode: :major) + {_, lua} = Lua.eval!(lua, unquote(write)) + + assert_minor_keeps_live(lua) + lua = Lua.gc(lua, mode: :minor) + assert {[1], _} = Lua.eval!(lua, unquote(read)) + end + end + + test "keeps a young table written through Lua.set!" do + {_, lua} = Lua.eval!(Lua.new(), "old = {}") + lua = Lua.gc(lua, mode: :major) + lua = Lua.set!(lua, [:old, :k], %{"v" => 1}) + + assert_minor_keeps_live(lua) + lua = Lua.gc(lua, mode: :minor) + assert {[1], _} = Lua.eval!(lua, "return old.k.v") + end + + test "keeps the remembered set across a caught error" do + {_, lua} = Lua.eval!(Lua.new(), "old = {} function boom() old.k = {v = 1} error('x') end") + lua = Lua.gc(lua, mode: :major) + {:error, _, lua} = Lua.call_function(lua, [:boom], []) + + assert_minor_keeps_live(lua) + lua = Lua.gc(lua, mode: :minor) + assert {[1], _} = Lua.eval!(lua, "return old.k.v") + end + end + + describe "auto" do + test "first collection is major and sets the pacing threshold" do + lua = Lua.gc(Lua.new()) + assert lua.state.gc.major_at > 0 + assert lua.state.gc_young_from == lua.state.table_next_id + end + + test "does nothing until enough tables have been allocated" do + lua = Lua.gc(Lua.new()) + {_, lua} = Lua.eval!(lua, "for i = 1, 10 do local t = {} end") + assert Lua.gc(lua) == lua + end + + test "keeps a long-running soak bounded" do + {_, lua} = Lua.eval!(Lua.new(), @soak) + lua = Lua.gc(lua) + {baseline, _} = heap_size(lua) + + {lua, peak} = + Enum.reduce(1..1_000, {lua, 0}, fn n, {lua, peak} -> + {:ok, [^n], lua} = Lua.call_function(lua, [:step], []) + lua = Lua.gc(lua) + {tables, _} = heap_size(lua) + {lua, max(peak, tables)} + end) + + # Garbage accumulates only until the next minor collection. + assert peak - baseline < 1_200 + assert {[1001], _} = Lua.eval!(lua, "return step()") + end + + test "collects a workload that allocates closure cells but no tables" do + {_, lua} = + Lua.eval!(Lua.new(), """ + function step() + local x = 0 + local f = function() return x end + return f() + end + """) + + lua = Lua.gc(lua) + {_, baseline} = heap_size(lua) + + lua = + Enum.reduce(1..3_000, lua, fn _, lua -> + {:ok, [0], lua} = Lua.call_function(lua, [:step], []) + Lua.gc(lua) + end) + + {_, cells} = heap_size(lua) + assert cells - baseline < 1_100 + end + + test "collects a workload that allocates userdata but no tables" do + lua = + Lua.set!(Lua.new(), [:make], fn _args, lua -> + {ud, lua} = Lua.encode!(lua, {:userdata, :payload}) + {[ud], lua} + end) + + {_, lua} = Lua.eval!(lua, "function step() local u = make() end") + lua = Lua.gc(lua) + + lua = + Enum.reduce(1..5_000, lua, fn _, lua -> + {:ok, _, lua} = Lua.call_function(lua, [:step], []) + Lua.gc(lua) + end) + + assert map_size(lua.state.userdata) < 3_000 + end + end + + describe "roots" do + test "a Lua.set! callback keeps the tables it captured alive" do + {[t], lua} = Lua.eval!(Lua.new(), "return {x = 1}", decode: false) + t = Lua.unwrap(t) + lua = Lua.set!(lua, [:get], fn _ -> [t] end) + lua = Lua.gc(lua, mode: :major) + + assert {[1], _} = Lua.eval!(lua, "return get().x") + end + + test "a callback that captured a Lua state does not pin that state's heap" do + {_, lua} = Lua.eval!(Lua.new(), "big = {} for i = 1, 100 do big[i] = {} end") + snapshot = lua + lua = Lua.set!(lua, [:probe], fn _ -> [map_size(snapshot.state.tables)] end) + {_, lua} = Lua.eval!(lua, "big = nil") + before = map_size(lua.state.tables) + + lua = Lua.gc(lua, mode: :major) + assert map_size(lua.state.tables) <= before - 101 + end + + test "a major collection frees unreachable userdata and what only it referenced" do + {_, lua} = Lua.eval!(Lua.new(), "inner = {v = 1}") + inner = lua |> Lua.get!([:inner], decode: false) |> Lua.unwrap() + {ud, lua} = Lua.encode!(lua, {:userdata, inner}) + lua = Lua.set!(lua, [:holder], ud) + {_, lua} = Lua.eval!(lua, "inner = nil") + + lua = Lua.gc(lua, mode: :major) + assert map_size(lua.state.userdata) == 1 + assert {[1], lua} = Lua.eval!(lua, "return 1") + tables = map_size(lua.state.tables) + + {_, lua} = Lua.eval!(lua, "holder = nil") + lua = Lua.gc(lua, mode: :major) + assert lua.state.userdata == %{} + assert map_size(lua.state.tables) == tables - 1 + end + + test "a deep object graph is traced without deep recursion" do + {_, lua} = Lua.eval!(Lua.new(), "local n = nil for i = 1, 200000 do n = {next = n} end chain = n") + lua = Lua.gc(lua, mode: :major) + + assert map_size(lua.state.tables) > 200_000 + assert {[200_000], _} = Lua.eval!(lua, "local c, n = 0, chain while n do c = c + 1 n = n.next end return c") + end + end + + test "string.gmatch iterators leave nothing behind once unreachable" do + {_, lua} = + Lua.eval!(Lua.new(), """ + function step() + local n = 0 + for w in string.gmatch("a b c", "%a") do n = n + 1 end + return n + end + """) + + lua = Lua.gc(lua, mode: :major) + baseline = {map_size(lua.state.private), map_size(lua.state.upvalue_cells)} + + lua = + Enum.reduce(1..50, lua, fn _, lua -> + {:ok, [3], lua} = Lua.call_function(lua, [:step], []) + lua + end) + + lua = Lua.gc(lua, mode: :major) + assert {map_size(lua.state.private), map_size(lua.state.upvalue_cells)} == baseline + end + + test "rejects an unknown mode" do + assert_raise ArgumentError, ~r/:mode must be/, fn -> Lua.gc(Lua.new(), mode: :full) end + end + + test "closures keep their captured tables and shared cells alive" do + {_, lua} = + Lua.eval!(Lua.new(), """ + local data = {value = 42} + local n = 0 + get = function() return data.value end + inc = function() n = n + 1; return n end + peek = function() return n end + """) + + lua = Lua.gc(lua, mode: :major) + {[42], lua} = Lua.eval!(lua, "return get()") + {[1], lua} = Lua.eval!(lua, "return inc()") + lua = Lua.gc(lua, mode: :major) + assert {[1], _} = Lua.eval!(lua, "return peek()") + end + + test "metatables and string methods survive" do + {_, lua} = + Lua.eval!(Lua.new(), """ + cls = setmetatable({}, {__index = function(_, k) return k .. "!" end}) + """) + + lua = Lua.gc(lua, mode: :major) + assert {["hi!", "ABC"], _} = Lua.eval!(lua, "return cls.hi, ('abc'):upper()") + end + + test ":keep roots values held only by Elixir" do + {[t], lua} = Lua.eval!(Lua.new(), "return {answer = 42}", decode: false) + lua = Lua.gc(lua, keep: [t], mode: :major) + assert Lua.decode!(lua, t) == [{"answer", 42}] + end + + describe "refuses to run while Lua frames are active" do + setup do + [lua: Lua.set!(Lua.new(), [:collect], fn _args, lua -> {[], Lua.gc(lua, mode: :major)} end)] + end + + test "from a callback in the main chunk", %{lua: lua} do + assert_raise Lua.RuntimeException, ~r/must not be called while Lua code is running/, fn -> + Lua.eval!(lua, "local t = {v = 42} collect() return t.v") + end + end + + test "from a callback inside a pcall'd function", %{lua: lua} do + assert {[false, message], _} = + Lua.eval!(lua, "local t = {v = 42} return pcall(function() collect() end)") + + assert message =~ "must not be called while Lua code is running" + end + + test "from a callback reached through call_function", %{lua: lua} do + {_, lua} = Lua.eval!(lua, "function f() local t = {v = 42} collect() return t.v end") + assert {:error, %Lua.RuntimeException{}, _} = Lua.call_function(lua, [:f], []) + assert {:error, %Lua.RuntimeException{}, _} = Lua.call_function(lua, [:collect], []) + end + end + + test "is allowed again after a call returns or raises" do + {_, lua} = Lua.eval!(Lua.new(), "function boom() error('x') end") + {[false, _], lua} = Lua.eval!(lua, "return pcall(boom)") + {:error, _, lua} = Lua.call_function(lua, [:boom], []) + assert lua.state.entry_depth == 0 + assert %Lua{} = Lua.gc(lua, mode: :major) + end +end From 8906fd1ba7e319eee36f50fbaf529093f9a605db Mon Sep 17 00:00:00 2001 From: David Bernheisel Date: Mon, 19 Oct 2026 12:51:16 -0400 Subject: [PATCH 2/3] docs(vm): describe Lua.gc/2 pacing inline instead of linking a hidden module Lua.VM.* modules are filtered out of the published docs, so the link to Lua.VM.GC made ExDoc warn. --- lib/lua.ex | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/lib/lua.ex b/lib/lua.ex index 8695765..6bf61e1 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -1193,12 +1193,11 @@ defmodule Lua do evaluations. Calling it after every evaluation is cheap: by default it does nothing until enough new objects have been allocated, and then usually traces only the objects created since the previous collection. - See `Lua.VM.GC` for the pacing rules. Anything reachable from globals, metatables, private storage, Elixir - callbacks or `:keep` survives. A table or function reference held only by Elixir code is - **not** a root and may dangle afterwards — pass it in `:keep`, or store - it in a global first. + callbacks or `:keep` survives. A table or function reference held only + by Elixir code is **not** a root and may dangle afterwards — pass it in + `:keep`, or store it in a global first. Must be called between evaluations, not from inside an Elixir callback that Lua is running. @@ -1206,9 +1205,15 @@ defmodule Lua do ## Options * `:keep` - (default `[]`) extra values to treat as roots. - * `:mode` - (default `:auto`) `:auto` paces itself; `:minor` collects - only the objects created since the last collection; `:major` traces - the whole heap. + * `:mode` - (default `:auto`) one of: + * `:auto` - does nothing until 1,000 tables, closure cells and + userdata have been allocated since the last collection, then runs + a minor collection, followed by a major one if the heap has doubled + since the last major collection. The first `:auto` collection on a + state is always major. + * `:minor` - frees only unreachable objects created since the last + collection. Older garbage waits for a major collection. + * `:major` - traces and frees the whole heap. iex> lua = Lua.new() iex> {_, lua} = Lua.eval!(lua, "for i = 1, 100 do local t = {i} end") From f5d18a50ded77a80b441a27e0c2131d37a760298 Mon Sep 17 00:00:00 2001 From: David Bernheisel Date: Thu, 22 Oct 2026 16:12:41 -0400 Subject: [PATCH 3/3] feat(vm): collect after reentrant calls with Lua.gc_reentrant/2 A host that serves another call while Lua waits in an Elixir callback runs that call on the suspended state. Lua.gc/2 refuses to run there, so every reentrant call's garbage stayed until the outer evaluation returned, and a long wait could grow the heap without bound. Lua.gc_reentrant/2 fences the heap by promoting every object to the old generation, runs the call, and then minor-collects only what the call allocated and left unreachable. The suspended frames can hold only objects that predate the fence, so they survive. The call must return at the entry depth it started from. --- lib/lua.ex | 32 +++++++++++++++ lib/lua/vm/gc.ex | 37 ++++++++++++++++++ test/lua/vm/gc_test.exs | 87 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+) diff --git a/lib/lua.ex b/lib/lua.ex index 6bf61e1..cfc61cf 100644 --- a/lib/lua.ex +++ b/lib/lua.ex @@ -1232,6 +1232,38 @@ defmodule Lua do %{lua | state: GC.collect(state, Keyword.fetch!(opts, :keep), mode)} end + @doc """ + Runs `fun` as a reentrant call on `lua`, then frees what it allocated and left unreachable. + + `Lua.gc/2` cannot run while Lua code is running. A host that serves + another call while Lua waits in an Elixir callback would otherwise keep + every reentrant call's garbage until the outer evaluation returns. Wrap + each reentrant call in this function instead: it fences the heap, calls + `fun` with the fenced state, and runs a minor collection that frees only + objects allocated during `fun`. Objects the suspended frames hold + predate the fence and survive. + + `fun` returns `{result, lua}`, and its state must be back at the entry + depth it was given, with every frame it started returned. Values in + `result` are kept, as are globals, private values and anything reachable + from them. + + iex> lua = Lua.set!(Lua.new(), [:serve], fn _args, lua -> + ...> Lua.gc_reentrant(lua, fn lua -> + ...> {:ok, results, lua} = Lua.call_function(lua, [:handle], []) + ...> {results, lua} + ...> end) + ...> end) + iex> {_, lua} = Lua.eval!(lua, "function handle() local t = {} for i = 1, 10 do t[i] = {} end return #t end") + iex> {[10], _} = Lua.eval!(lua, "return serve()") + """ + @spec gc_reentrant(t(), (t() -> {result, t()})) :: {result, t()} when result: term() + def gc_reentrant(%__MODULE__{state: state} = lua, fun) when is_function(fun, 1) do + fenced = GC.fence(state) + {result, %__MODULE__{} = lua} = fun.(%{lua | state: fenced}) + {result, %{lua | state: GC.reentrant(lua.state, fenced, [result])}} + end + # Note: These functions are called from load_api and always go through set!/3's # arity-2 clause, which wraps the state as %Lua{} before calling. So `lua` is # already a %Lua{} struct — do NOT call wrap() again. diff --git a/lib/lua/vm/gc.ex b/lib/lua/vm/gc.ex index 38fd76f..91332e1 100644 --- a/lib/lua/vm/gc.ex +++ b/lib/lua/vm/gc.ex @@ -44,6 +44,14 @@ defmodule Lua.VM.GC do an Elixir callback that Lua is running. This is also why in-Lua `collectgarbage("collect")` stays a no-op. + `reentrant/3` is the one exception. A host that serves another call + while Lua waits in a callback runs that call on the suspended state. + The suspended frames can only hold objects that existed before the call + began, so `fence/1` first promotes every object to the old generation. + Once the reentrant call returns to the fenced entry depth, a minor + collection frees only what that call allocated and left unreachable; + everything older is assumed live. + ## Roots `_G`, the type metatables, every private value, any open upvalue cells, @@ -83,6 +91,35 @@ defmodule Lua.VM.GC do def collect(%State{} = state, keep, :minor), do: minor(state, keep) def collect(%State{} = state, keep, :major), do: major(state, keep) + @doc """ + Promotes every object to the old generation, so that a later minor + collection traces and frees only objects allocated after this point. + Frees nothing, so it is safe while Lua code is running. + """ + @spec fence(State.t()) :: State.t() + def fence(%State{gc: gc} = state) do + %{ + state + | gc_young_from: state.table_next_id, + gc_remembered: %{}, + gc: %{gc | old_cells: state.upvalue_cells, userdata_from: state.userdata_next_id} + } + end + + @doc """ + Minor-collects `state` after a reentrant call made on `fenced` returns. + + `state` must be back at `fenced`'s entry depth: every frame the call + started has returned, and the frames suspended at the fence held only + objects that existed before it. + """ + @spec reentrant(State.t(), State.t(), [term()]) :: State.t() + def reentrant(%State{entry_depth: depth} = state, %State{entry_depth: depth}, keep), do: minor(state, keep) + + def reentrant(%State{}, %State{}, _keep) do + raise ArgumentError, "a reentrant call must return its state at the same entry depth it started from" + end + defp young_count(%State{gc: gc} = state) do state.table_next_id - state.gc_young_from + (map_size(state.upvalue_cells) - map_size(gc.old_cells)) + diff --git a/test/lua/vm/gc_test.exs b/test/lua/vm/gc_test.exs index 22abf41..9d3ae1d 100644 --- a/test/lua/vm/gc_test.exs +++ b/test/lua/vm/gc_test.exs @@ -353,6 +353,93 @@ defmodule Lua.VM.GCTest do end end + describe "gc_reentrant/2" do + @churn """ + function churn() + local t = {} + for i = 1, 200 do t[i] = {i} end + published = {from_churn = true} + return #t + end + """ + + # `host()` reenters Lua while its caller is suspended, as a host that serves another call + # during a wait does, and reports the heap around the collection. + defp reentering(name, function) do + owner = self() + + Lua.set!(Lua.new(), [name], fn _args, lua -> + {n, collected} = + Lua.gc_reentrant(lua, fn lua -> + {:ok, [n], lua} = Lua.call_function(lua, [function], []) + send(owner, {:before, Map.keys(lua.state.tables)}) + {n, lua} + end) + + send(owner, {:after, Map.keys(collected.state.tables)}) + {[n], collected} + end) + end + + test "frees what the reentrant call left and keeps the suspended caller's objects" do + {_, lua} = Lua.eval!(reentering(:host, :churn), @churn) + lua = Lua.gc(lua, mode: :major) + + {[7, 200, true], lua} = + Lua.eval!(lua, """ + local before = {value = 7} + local n = host() + return before.value, n, published.from_churn + """) + + assert_received {:before, before} + assert_received {:after, kept} + assert length(before) - length(kept) >= 200 + assert lua.state.entry_depth == 0 + + # Never more aggressive than a full trace of the finished state. + live = Map.keys(Lua.gc(lua, mode: :major).state.tables) + assert Enum.reject(live, &(&1 in kept or &1 >= lua.state.gc_young_from)) == [] + end + + test "nests: an inner collection keeps the middle call's objects" do + lua = reentering(:inner_host, :churn) + + lua = + Lua.set!(lua, [:outer_host], fn _args, lua -> + Lua.gc_reentrant(lua, fn lua -> + {:ok, results, lua} = Lua.call_function(lua, [:middle], []) + {results, lua} + end) + end) + + {_, lua} = + Lua.eval!( + lua, + @churn <> + """ + function middle() + local mine = {value = 5} + local n = inner_host() + return mine.value + n + end + """ + ) + + assert {[205, 9], _} = + Lua.eval!(lua, "local outer = {value = 9} local r = outer_host() return r, outer.value") + end + + test "refuses a function that returns at another entry depth" do + lua = + Lua.set!(Lua.new(), [:host], fn _args, lua -> + Lua.gc_reentrant(lua, fn _lua -> {[], Lua.new()} end) + end) + + assert_raise Lua.RuntimeException, ~r/same entry depth/, fn -> Lua.eval!(lua, "host()") end + end + end + test "is allowed again after a call returns or raises" do {_, lua} = Lua.eval!(Lua.new(), "function boom() error('x') end") {[false, _], lua} = Lua.eval!(lua, "return pcall(boom)")