arizona_reloader_consistency (arizona v0.4.0)
View SourcePost-reload call-consistency check for the dev hot reloader.
arizona_reloader hot-loads recompiled modules one at a time. Nothing
otherwise verifies that the set of loaded modules stays call-consistent
after a wave, so two common dev situations leave the node running a mix of
module versions that each compile fine but cannot call each other:
- an exported function changes arity (or is renamed) in module
Awhile a callerBis not recompiled/reloaded in the same wave -- because the build tool skipped it on stale_buildbeam mtimes (a branch switch, or another checkout stamping beams newer than the sources), or because the reload batch raced; - a module is running from a beam that is older than its source on disk.
The mismatch stays silent until the first runtime call, which raises
undef. When that call sits on a hot path (a per-tick gen_server), the
process crash-loops, exhausts its supervisor's restart intensity within
seconds, and takes the whole tree down -- while route serving keeps working, so
the app looks up but is dead behind it. The mismatch is machine-detectable the
moment it exists.
What this does
After each reload wave the reloader calls check/1, which:
- Collects the just-reloaded module set from the changed
.erlfiles (reloaded_modules/1), keeping only modules that are currently loaded. - For every loaded application module (
candidate_modules/0-- the loaded set minus OTP, filtered to modules with a readable beam file), reads its static external-call table and reports every edge into a reloaded module whose target function the reloaded module no longer exports (broken_edges/2). - Optionally flags any loaded module whose in-memory version differs from its
beam on disk (
stale_modules/1), which catches the mtime-skip case even before a call breaks. - Logs one loud
warningper finding, naming the caller and the missing MFA.
The two edge signals are complementary. A caller's call table is read from its
beam on disk, so broken_edges/2 catches the classic stale-caller-on-disk case
(the caller was never recompiled). stale_modules/1 catches the opposite case
-- a caller whose disk beam is fresh but whose in-memory code is stale -- via
the version mismatch.
Detection mechanism
The static call table comes from the beam imports chunk via
beam_lib:chunks/2 (stdlib), not xref -- so there is no runtime dependency on
the tools application and no xref server to start. Dynamic calls (apply/3,
behaviour callbacks resolved at runtime) are invisible to the import table; that
is fine, the check is best-effort and the static case is the one that bites.
The pass runs synchronously in the watcher on every save, over every loaded
non-OTP module, so its disk work is kept minimal: each candidate's imports
and attributes (vsn) chunks are fetched in one beam_lib pass, and the
result is cached across waves keyed by the beam file's path + mtime -- an
unchanged module costs one stat per wave, not repeated full reads. The wave's
just-reloaded modules are evicted up front (their beams were rewritten, and an
mtime has whole-second resolution), so their fresh facts are always re-read.
The cache table is created and owned by arizona_sup (create_table/0), not by
whichever process runs a check, so that "one stat" holds for every caller --
including the dev MCP tools, whose checks run in a per-dispatch process that
exits as soon as it has answered.
The whole pass is best-effort: it runs only on a successful reload (dev
mode), finds nothing and logs nothing on a clean wave, and never crashes or
interferes with the reload -- check/1 wraps everything in a catch-all.
Summary
Functions
Every broken call edge from a candidate caller into a reloaded module: a
{Caller, {Mod, Fun, Arity}} for each static call in Caller's beam whose
target Mod is in Reloaded and whose Mod:Fun/Arity the currently-loaded
Mod no longer exports.
The application modules to scan as candidate callers: every loaded module with a readable beam file that is not part of OTP. Excluding OTP keeps the sweep to project and dependency code (an OTP module never calls a just-changed app module) and cheap.
Runs the consistency pass over the wave described by Files (the changed
source files) and logs one warning per finding. Always returns ok.
Creates the beam-facts cache table. Called once by arizona_sup:init/1, so the
supervisor owns it and the cache spans the node's lifetime.
The modules just reloaded in this wave: the module name of each changed .erl
file that is currently loaded. A not-yet-loaded module is dropped -- lazy
loading is normal, not a mismatch.
Every candidate module whose in-memory version differs from its beam on disk --
a {Mod, LoadedVsn, DiskVsn} per stale module. A module whose loaded or disk
version cannot be read is skipped.
Types
Functions
-spec broken_edges(Reloaded, Candidates) -> [broken_edge()] when Reloaded :: [module()], Candidates :: [module()].
Every broken call edge from a candidate caller into a reloaded module: a
{Caller, {Mod, Fun, Arity}} for each static call in Caller's beam whose
target Mod is in Reloaded and whose Mod:Fun/Arity the currently-loaded
Mod no longer exports.
Scoped to Reloaded targets so only edges this wave could have broken are
reported, not any pre-existing unrelated undef.
-spec candidate_modules() -> [module()].
The application modules to scan as candidate callers: every loaded module with a readable beam file that is not part of OTP. Excluding OTP keeps the sweep to project and dependency code (an OTP module never calls a just-changed app module) and cheap.
-spec check(Files) -> ok when Files :: [file:filename()].
Runs the consistency pass over the wave described by Files (the changed
source files) and logs one warning per finding. Always returns ok.
Best-effort by design: a post-reload probe must never crash or interfere with the reload it follows, so the whole pass is wrapped in a catch-all. On a clean reload it finds nothing and logs nothing.
-spec create_table() -> ok.
Creates the beam-facts cache table. Called once by arizona_sup:init/1, so the
supervisor owns it and the cache spans the node's lifetime.
The owner has to be stable because the checks run from wherever the caller
happens to be: the watcher process on a reload wave, but also a per-dispatch
spawn_linked MCP worker that exits as soon as it has answered. Creating the
table on demand made the first such caller its owner, so the cache died with a
worker that lived microseconds -- every MCP call then re-read every beam, and a
concurrent caller could see the table disappear between its own existence check
and its lookup.
Not gated on the reloader being enabled: the dev MCP drift check calls in
regardless, and an empty named table is a negligible footprint (the same
reasoning arizona_mcp_sup applies to its registry).
-spec reloaded_modules(Files) -> [module()] when Files :: [file:filename()].
The modules just reloaded in this wave: the module name of each changed .erl
file that is currently loaded. A not-yet-loaded module is dropped -- lazy
loading is normal, not a mismatch.
Every candidate module whose in-memory version differs from its beam on disk --
a {Mod, LoadedVsn, DiskVsn} per stale module. A module whose loaded or disk
version cannot be read is skipped.