diff --git a/lib/lua.ex b/lib/lua.ex index e055366..cfc61cf 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,85 @@ 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. + + 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`) 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") + 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 + + @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.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..91332e1 --- /dev/null +++ b/lib/lua/vm/gc.ex @@ -0,0 +1,282 @@ +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. + + `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, + 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) + + @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)) + + (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..9d3ae1d --- /dev/null +++ b/test/lua/vm/gc_test.exs @@ -0,0 +1,450 @@ +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 + + 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)") + {:error, _, lua} = Lua.call_function(lua, [:boom], []) + assert lua.state.entry_depth == 0 + assert %Lua{} = Lua.gc(lua, mode: :major) + end +end