Packages
Coyote-style controlled concurrency testing for the BEAM. PCT/POS scheduling, schedule shrinking, trace replay, Elixir + Erlang AST rewriters, and ETS / atomics / persistent_term sync points -- so message-passing AND shared-state races surface deterministically.
Current section
Files
Jump to
Current section
Files
lib/lockstep/linter.ex
defmodule Lockstep.Linter do
@moduledoc """
Compile-time AST scanner that warns when a `ctest` body uses operations
that aren't routed through Lockstep's controller. Bare `send`, bare
`receive`, `Process.send_after`, `GenServer.call`, `Task.async`, and
similar will silently break the controlled schedule -- better to warn
at compile time than have the user wonder why nothing reproduces.
False positives (your project genuinely defines `MyMod.send/2` and
the linter flags `send/2` calls) can be silenced by qualifying the
call (`Kernel.send(...)`) or by `import`ing nothing.
"""
@forbidden [
{:bare, :send, 2, "bare send/2", "use Lockstep.send/2"},
{:bare, :spawn, 1, "bare spawn/1", "use Lockstep.spawn/1"},
{:bare, :spawn_link, 1, "bare spawn_link/1", "use Lockstep.spawn_link/1"},
{:special, :receive, "bare receive", "use Lockstep.recv/0 or Lockstep.recv_first/1"},
# ExUnit receive-based assertions
{:bare, :assert_receive, :any,
"assert_receive (uses the BEAM mailbox, not Lockstep's controller)",
"assert via Lockstep.recv_first/1 instead"},
{:bare, :assert_received, :any,
"assert_received (uses the BEAM mailbox, not Lockstep's controller)",
"assert via Lockstep.recv_first/1 instead"},
{:bare, :refute_receive, :any,
"refute_receive (uses the BEAM mailbox, not Lockstep's controller)",
"model the absence-of-message via Lockstep.recv_first/1 with a timeout"},
{:bare, :refute_received, :any,
"refute_received (uses the BEAM mailbox, not Lockstep's controller)",
"model the absence-of-message via Lockstep.recv_first/1 with a timeout"},
# Qualified calls that should be Lockstep equivalents
{:qual, Kernel, :send, 2, "Kernel.send/2", "use Lockstep.send/2"},
{:qual, :erlang, :send, 2, ":erlang.send/2", "use Lockstep.send/2"},
{:qual, Process, :send_after, :any, "Process.send_after", "use Lockstep.send_after/3"},
{:qual, Process, :cancel_timer, :any, "Process.cancel_timer", "use Lockstep.cancel_timer/1"},
{:qual, Process, :link, :any, "Process.link", "use Lockstep.link/1"},
{:qual, Process, :unlink, :any, "Process.unlink", "use Lockstep.unlink/1"},
{:qual, Process, :flag, :any, "Process.flag", "use Lockstep.flag/2"},
{:qual, Process, :alive?, :any, "Process.alive?", "use Lockstep.alive?/1"},
{:qual, Process, :sleep, :any, "Process.sleep", "use Lockstep.sleep/1"},
{:qual, Process, :monitor, :any, "Process.monitor", "use Lockstep.monitor/1"},
{:qual, Process, :demonitor, :any, "Process.demonitor", "use Lockstep.demonitor/2"},
{:qual, GenServer, :call, :any, "GenServer.call", "use Lockstep.GenServer.call/2"},
{:qual, GenServer, :cast, :any, "GenServer.cast", "use Lockstep.GenServer.cast/2"},
{:qual, GenServer, :start_link, :any, "GenServer.start_link",
"use Lockstep.GenServer.start_link/2"},
{:qual, Task, :async, :any, "Task.async", "use Lockstep.Task.async/1"},
{:qual, Task, :await, :any, "Task.await", "use Lockstep.Task.await/1"}
]
@doc """
Walk `ast` and emit `IO.warn/2` for each unwrapped operation found.
Always returns the AST unchanged.
"""
def lint(ast, env) do
Macro.prewalk(ast, fn node ->
check_node(node, env)
node
end)
end
defp check_node(node, env) do
Enum.each(@forbidden, fn rule ->
case match_rule(rule, node) do
{:ok, meta, kind, suggestion} ->
warn(env, meta, "#{kind} in ctest body -- " <> suggestion)
:no ->
:ok
end
end)
end
# Special form (receive)
defp match_rule({:special, name, kind, suggestion}, {name, meta, _}),
do: {:ok, meta, kind, suggestion}
# Bare call by name + arity
defp match_rule({:bare, name, :any, kind, suggestion}, {name, meta, args})
when is_list(args),
do: {:ok, meta, kind, suggestion}
defp match_rule({:bare, name, arity, kind, suggestion}, {name, meta, args})
when is_list(args) and length(args) == arity,
do: {:ok, meta, kind, suggestion}
# Qualified call M.f(args) by module + name + arity
defp match_rule({:qual, mod, fun, arity, kind, suggestion}, node) do
case node do
{{:., _, [callee, ^fun]}, meta, args} when is_list(args) ->
if module_matches?(callee, mod) and arity_matches?(args, arity) do
{:ok, meta, kind, suggestion}
else
:no
end
_ ->
:no
end
end
defp match_rule(_, _), do: :no
defp module_matches?({:__aliases__, _, parts}, target) when is_atom(target) do
try do
Module.concat(parts) == target
rescue
_ -> false
end
end
defp module_matches?(atom, atom) when is_atom(atom), do: true
defp module_matches?(_, _), do: false
defp arity_matches?(_args, :any), do: true
defp arity_matches?(args, n) when is_integer(n), do: length(args) == n
defp warn(env, meta, msg) do
line = Keyword.get(meta, :line, env.line || 0)
stacktrace_entry =
{env.module, env.function || :__unknown__, 1, file: ~c"#{env.file}", line: line}
IO.warn("[lockstep] " <> msg, [stacktrace_entry])
end
end