Current section
Files
Jump to
Current section
Files
lib/repatch.ex
defmodule Repatch do
@moduledoc """
Final word in Elixir testing
"""
alias Repatch.Recompiler
@forbidden_modules [
Keyword,
Repatch,
Repatch.Recompiler,
Repatch.ExUnit,
Enum,
:erlang,
:code,
:ets,
:persistent_term
]
if false do
defmacrop debug(message) do
quote do
IO.puts("#{inspect(self())} #{unquote(message)}")
end
end
else
defmacrop debug(_), do: nil
end
@type setup_option ::
recompile_option()
| {:enable_global, boolean()}
| {:enable_shared, boolean()}
| {:enable_history, boolean()}
| {:recompile, module() | [module()]}
@type mode :: :local | :shared | :global
@type tag :: :patched | mode()
@type recompile_option :: {:ignore_forbidden_module, boolean()}
@spec setup([setup_option()]) :: :ok
def setup(opts \\ []) do
global_hooks_enabled = Keyword.get(opts, :enable_global)
shared_hooks_enabled = Keyword.get(opts, :enable_shared)
history_enabled = Keyword.get(opts, :enable_history)
:ets.new(:repatch_module_states, [:set, :named_table, :public])
:ets.new(:repatch_global_lock, [:set, :named_table, :public])
:ets.new(:repatch_state, [:set, :named_table, :public])
case global_hooks_enabled do
nil ->
:ok
true ->
:ets.new(:repatch_global_hooks, [:set, :named_table, :public])
:persistent_term.put(:repatch_global_hooks_enabled, true)
false ->
:persistent_term.put(:repatch_global_hooks_enabled, false)
end
case shared_hooks_enabled do
nil ->
:ets.new(:repatch_shared_hooks, [:set, :named_table, :public])
:ets.new(:repatch_shared_allowances, [:set, :named_table, :public])
true ->
:ets.new(:repatch_shared_hooks, [:set, :named_table, :public])
:ets.new(:repatch_shared_allowances, [:set, :named_table, :public])
:persistent_term.put(:repatch_shared_hooks_enabled, true)
false ->
:persistent_term.put(:repatch_shared_hooks_enabled, false)
end
case history_enabled do
nil ->
:ets.new(:repatch_history, [:duplicate_bag, :named_table, :public])
true ->
:ets.new(:repatch_history, [:duplicate_bag, :named_table, :public])
:persistent_term.put(:repatch_history_enabled, true)
false ->
:persistent_term.put(:repatch_history_enabled, false)
end
for module <- List.wrap(Keyword.get(opts, :recompile, [])) do
recompile(module, opts)
end
debug("setup successful")
:ok
end
@spec cleanup(pid()) :: :ok
def cleanup(pid \\ self()) do
if pid == self() do
Enum.each(:erlang.get_keys(), fn
{:repatch_hooks, _, _, _} = key -> :erlang.erase(key)
_ -> :ok
end)
debug("cleanup local")
end
if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
:ets.match_delete(:repatch_shared_hooks, {{:_, :_, :_, pid}, :_})
:ets.match_delete(:repatch_shared_allowances, {:_, pid})
:ets.match_delete(:repatch_shared_allowances, {pid, :_})
end
if :persistent_term.get(:repatch_global_hooks_enabled, false) do
:ets.match_delete(:repatch_global_hooks, {{:_, :_, :_}, pid, :_})
end
cleanup_history(pid)
debug("cleanup done")
:ok
end
defp cleanup_history(pid) do
if :persistent_term.get(:repatch_history_enabled, true) do
:ets.match_delete(:repatch_history, {{:_, :_, :_, pid}, :_, :_})
end
end
@spec restore_all() :: :ok
def restore_all do
:repatch_module_states
|> :ets.tab2list()
|> Enum.each(fn {module, state} ->
case state do
{:recompiled, original_binary} ->
Recompiler.load_binary(module, original_binary)
:recompiling ->
await_recompilation(module)
_ ->
:ok
end
end)
:ets.delete_all_objects(:repatch_module_states)
:ets.delete_all_objects(:repatch_state)
:ets.delete_all_objects(:repatch_history)
:ets.delete_all_objects(:repatch_global_hooks)
:ets.delete_all_objects(:repatch_shared_hooks)
:ok
end
@type spy_option :: recompile_option() | {:by, pid()}
@spec spy(module(), [spy_option()]) :: :ok
def spy(module, opts \\ []) do
pid = Keyword.get(opts, :by, self())
recompile(module, opts)
cleanup_history(pid)
:ok
end
@type fake_option :: recompile_option() | {:mode, mode()}
@spec fake(module(), module(), [fake_option()]) :: :ok
def fake(real_module, fake_module, opts \\ [])
def fake(m, m, _opts) do
raise ArgumentError, "Can't fake module with itself #{inspect(m)}"
end
def fake(real_module, fake_module, opts) do
exports = real_module.module_info(:exports)
recompile(real_module, opts)
mode = Keyword.get(opts, :mode, :local)
for {function, arity} <- exports do
hook = fn args -> {:ok, apply(fake_module, function, args)} end
add_hook(real_module, function, arity, hook, mode)
end
:ok
end
defmacro real({{:., _dotmeta, [_module, _function]}, _meta, _args} = call) do
quote do
:erlang.put(:repatch_bypass_hooks, true)
try do
unquote(call)
after
:erlang.erase(:repatch_bypass_hooks)
end
end
end
defmacro real(other) do
raise CompileError,
description:
"Expected call like Module.function(1, 2, 3). Got #{Code.format_string!(Macro.to_string(other))}",
line: __CALLER__.line,
file: __CALLER__.file
end
defmacro super({{:., _dotmeta, [module, function]}, meta, args}) do
super_function = Recompiler.super_name(function)
{:apply, meta, [module, super_function, args]}
end
defmacro super(other) do
raise CompileError,
description:
"Expected call like Module.function(1, 2, 3). Got #{Code.format_string!(Macro.to_string(other))}",
line: __CALLER__.line,
file: __CALLER__.file
end
defp recompile(module, opts) do
if module in @forbidden_modules and not Keyword.get(opts, :ignore_forbidden_module, false) do
raise ArgumentError,
"Module #{inspect(module)} is a forbidden to patch module, because it may interfere with the Repatch logic"
end
debug("Recompiling #{inspect(module)}")
case :ets.lookup(:repatch_module_states, module) do
[{_, :recompiling}] ->
debug("#{inspect(module)} awaiting recompilation")
await_recompilation(module)
[{_, {:recompiled, _}}] ->
debug("#{inspect(module)} found recompiled")
:ok
[] ->
if :ets.insert_new(:repatch_module_states, {module, :recompiling}) do
{:ok, bin} = Recompiler.recompile(module)
:ets.insert(:repatch_module_states, {module, {:recompiled, bin}})
debug("Recompiled #{inspect(module)}")
else
await_recompilation(module)
end
end
end
@type patch_option ::
recompile_option()
| {:mode, :local | :shared | :global}
| {:force, boolean()}
@spec patch(module(), atom(), [patch_option()], function()) :: :ok
def patch(module, function, opts \\ [], func) do
recompile(module, opts)
arity =
func
|> :erlang.fun_info()
|> Keyword.fetch!(:arity)
unless {function, arity} in module.module_info(:exports) do
raise ArgumentError, "Function #{inspect(module)}.#{function}/#{arity} does not exist"
end
mode = Keyword.get(opts, :mode, :local)
hook = prepare_hook(module, function, arity, func)
case :ets.lookup(:repatch_state, {module, function, arity, self()}) do
[] ->
add_hook(module, function, arity, hook, mode)
:ets.insert(:repatch_state, {{module, function, arity, self()}, [:patched, mode]})
[{_, tags}] ->
if :patched in tags and not Keyword.get(opts, :force) do
raise ArgumentError,
"Function #{inspect(module)}.#{function}/#{arity} is already patched"
else
add_hook(module, function, arity, hook, mode)
tags = [mode | tags -- [mode]]
:ets.insert(:repatch_state, {{module, function, arity, self()}, tags})
end
end
debug("Added #{mode} hook")
:ok
end
defp add_hook(module, function, arity, hook, mode) do
case mode do
:local ->
add_local_hook(module, function, arity, hook)
:shared ->
add_shared_hook(module, function, arity, hook)
:global ->
add_global_hook(module, function, arity, hook)
end
end
@type restore_option() :: {:mode, mode()}
@spec restore(module(), atom(), arity(), [restore_option()]) :: :ok
def restore(module, function, arity, opts \\ []) do
mode = Keyword.get(opts, :mode, :local)
case mode do
:local ->
remove_local_hook(module, function, arity)
:shared ->
remove_shared_hook(module, function, arity)
:global ->
remove_global_hook(module, function, arity)
end
with [{_, tags}] <- :ets.lookup(:repatch_state, {module, function, arity, self()}) do
tags = tags -- [:patched, :local, :shared, :global]
:ets.insert(:repatch_state, {{module, function, arity, self()}, tags})
end
:ok
end
@type allow_option :: {:force, boolean()}
@spec allow(pid(), pid(), [allow_option()]) :: :ok
def allow(owner, allowed, opts \\ []) do
unless :persistent_term.get(:repatch_shared_hooks_enabled, true) do
raise ArgumentError, "Shared hooks are disabled!"
end
final_owner =
case :ets.lookup(:repatch_shared_allowances, owner) do
[] ->
owner
[{_, final_owner}] ->
final_owner
end
if final_owner == allowed do
if allowed == owner do
raise ArgumentError, "Can't use allowance on the same process #{inspect(allowed)}"
else
raise ArgumentError,
"Cyclic allowance detected! #{inspect([allowed, owner, final_owner])}"
end
end
if Keyword.get(opts, :force, false) do
:ets.insert(:repatch_shared_allowances, {allowed, final_owner})
else
unless :ets.insert_new(:repatch_shared_allowances, {allowed, final_owner}) do
raise ArgumentError,
"Allowance is already present for the specified process #{inspect(allowed)}"
end
end
:ok
end
@spec allowances(pid()) :: [pid()]
def allowances(pid \\ self()) do
:repatch_shared_allowances
|> :ets.match({:"$1", pid})
|> Enum.concat()
end
@spec owner(pid()) :: pid() | nil
def owner(pid \\ self()) do
case :ets.lookup(:repatch_shared_allowances, pid) do
[{_, owner}] -> owner
_ -> nil
end
end
@spec info(module(), atom(), arity(), pid()) :: [tag()]
def info(module, function, arity, pid \\ self()) do
case :ets.lookup(:repatch_state, {module, function, arity, pid}) do
[{_, tags}] -> tags
_ -> []
end
end
@type repatched_check_option :: {:mode, mode() | :any}
@spec repatched?(module(), atom(), arity(), [repatched_check_option()]) :: boolean()
def repatched?(module, function, arity, opts \\ []) do
case Keyword.get(opts, :mode, :any) do
:any ->
:patched in info(module, function, arity)
mode ->
tags = info(module, function, arity)
:patched in tags and mode in tags
end
end
@type called_check_option ::
{:by, pid() | :any}
| {:at_least, :once | pos_integer()}
| {:exactly, :once | pos_integer()}
| {:after, monotonic_time_native :: integer()}
| {:before, monotonic_time_native :: integer()}
@spec called?(module(), atom(), arity() | [term()], [called_check_option()]) :: boolean()
def called?(module, function, arity_or_args, opts \\ []) do
exactly = intify(Keyword.get(opts, :exactly))
at_least = intify(Keyword.get(opts, :at_least, 1))
afterr = Keyword.get(opts, :after)
before = Keyword.get(opts, :before)
by = Keyword.get(opts, :by, self())
cond do
not :persistent_term.get(:repatch_history_enabled, true) ->
raise ArgumentError, "History disabled"
before && afterr && afterr > before ->
raise ArgumentError, "Can't have after more than before. Got #{afterr} > #{before}"
exactly && at_least && at_least > exactly ->
raise ArgumentError,
"When specifying exactly and at_least options, make sure that " <>
"at_least is always less than at_least. Got #{at_least} > #{exactly}"
!(exactly || at_least) ->
raise ArgumentError, "At least one parameter of exactly and at_least is required"
true ->
:ok
end
{arity, args} =
case arity_or_args do
args when is_list(args) ->
{length(args), args}
arity when is_integer(arity) and arity >= 0 ->
{arity, :_}
end
pid =
case by do
:any ->
:_
pid when is_pid(pid) ->
pid
other ->
raise ArgumentError, "Expected by option to be pid or `:any`. Got #{inspect(other)}"
end
module
|> called_pattern(function, arity, pid, args, afterr, before)
|> called_one(exactly, at_least)
end
defp called_pattern(module, function, arity, pid, args, nil, nil) do
{{{module, function, arity, pid}, :_, args}, [], [:"$$"]}
end
defp called_pattern(module, function, arity, pid, args, nil, before) do
{{{module, function, arity, pid}, :"$1", args}, [{:"=<", :"$1", before}], [:"$$"]}
end
defp called_pattern(module, function, arity, pid, args, afterr, nil) do
{{{module, function, arity, pid}, :"$1", args}, [{:>=, :"$1", afterr}], [:"$$"]}
end
defp called_pattern(module, function, arity, pid, args, afterr, before) do
{{{module, function, arity, pid}, :"$1", args},
[{:>=, :"$1", afterr}, {:"=<", :"$1", before}], [:"$$"]}
end
defp called_one(key_pattern, nil, at_least) do
called_at_least(key_pattern, at_least)
end
defp called_one(key_pattern, exactly, _at_least) do
called_exactly(key_pattern, exactly)
end
defp intify(nil), do: nil
defp intify(:once), do: 1
defp intify(i) when is_integer(i) and i > 0, do: i
defp intify(other) do
raise ArgumentError, "Expected positive integer or `:once` atom. Got #{inspect(other)}"
end
defp called_exactly(pattern, count) do
case :ets.select(:repatch_history, [pattern], count + 1) do
{list, :"$end_of_table"} -> length(list) == count
_ -> false
end
end
defp called_at_least(pattern, count) do
case :ets.select(:repatch_history, [pattern], count) do
{list, _} -> length(list) == count
_ -> false
end
end
defp await_recompilation(module) do
case :ets.lookup(:repatch_module_states, module) do
[{_, :recompiling}] ->
receive after: (10 -> [])
await_recompilation(module)
_ ->
:ok
end
end
defp add_local_hook(module, function, arity, hook) do
:erlang.put({:repatch_hooks, module, function, arity}, hook)
end
defp add_shared_hook(module, function, arity, hook) do
if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
:erlang.put({:repatch_hooks, module, function, arity}, hook)
:ets.insert(:repatch_shared_hooks, {{module, function, arity, self()}, hook})
else
raise ArgumentError, "Shared hooks disabled"
end
end
defp add_global_hook(module, function, arity, hook) do
if :persistent_term.get(:repatch_global_hooks_enabled, false) do
:ets.insert(:repatch_global_hooks, {{module, function, arity}, self(), hook})
else
raise ArgumentError, "Global hooks disabled"
end
end
defp remove_local_hook(module, function, arity) do
:erlang.erase({:repatch_hooks, module, function, arity})
end
defp remove_shared_hook(module, function, arity) do
if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
:erlang.erase({:repatch_hooks, module, function, arity})
:ets.delete(:repatch_shared_hooks, {module, function, arity, self()})
else
raise ArgumentError, "Shared hooks disabled"
end
end
defp remove_global_hook(module, function, arity) do
if :persistent_term.get(:repatch_global_hooks_enabled, false) do
:ets.delete(:repatch_global_hooks, {module, function, arity})
else
raise ArgumentError, "Global hooks disabled"
end
end
defp prepare_hook(_module, _function, _arity, hook) do
fn args -> {:ok, apply(hook, args)} end
end
@doc false
@spec dispatch(module(), atom(), arity(), [term()]) :: :pass | {:ok, term()}
def dispatch(module, function, arity, args) do
if :persistent_term.get(:repatch_history_enabled, true) do
ts = :erlang.monotonic_time()
:ets.insert(:repatch_history, {{module, function, arity, self()}, ts, args})
debug("Inserted history #{ts}")
end
case :erlang.get(:repatch_bypass_hooks) do
no when no in ~w[undefined false]a ->
case :erlang.get({:repatch_hooks, module, function, arity}) do
:undefined ->
dispatch_shared(module, function, arity, args)
hook ->
debug("Dispatched local")
hook.(args)
end
true ->
debug("Dispatched none")
:pass
end
end
defp dispatch_shared(module, function, arity, args) do
if :persistent_term.get(:repatch_shared_hooks_enabled, true) do
case :erlang.get({:repatch_shared_hook, module, function, arity}) do
:undefined ->
case :erlang.get(:repatch_shared_allowance) do
pid when is_pid(pid) ->
dispatch_allowance(module, function, arity, args, pid)
:undefined ->
case :ets.lookup(:repatch_shared_allowances, self()) do
[] ->
dispatch_global(module, function, arity, args)
[{_, pid}] when is_pid(pid) ->
:erlang.put(:repatch_shared_allowance, pid)
dispatch_allowance(module, function, arity, args, pid)
end
end
hook ->
debug("Dispatched shared")
hook.(args)
end
else
dispatch_global(module, function, arity, args)
end
end
defp dispatch_allowance(module, function, arity, args, pid) do
case :ets.lookup(:repatch_shared_hooks, {module, function, arity, pid}) do
[] ->
dispatch_global(module, function, arity, args)
[{_, hook}] when is_function(hook) ->
debug("Dispatched allowance")
hook.(args)
end
end
defp dispatch_global(module, function, arity, args) do
if :persistent_term.get(:repatch_global_hooks_enabled, false) do
case :ets.lookup(:repatch_global_hooks, {module, function, arity}) do
[] ->
:pass
[{_, _, hook}] ->
debug("Dispatched global")
hook.(args)
end
else
:pass
end
end
end