Packages
bond
1.2.1
1.13.0
1.12.0
1.11.0
1.10.1
1.10.0
1.9.0
1.8.0
1.7.0
1.6.0
1.5.0
1.4.0
1.3.0
1.3.0-rc.1
1.2.1
1.2.0
1.1.0
1.0.0
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
0.18.0
0.17.5
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.2
0.16.1
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.1
0.9.0
0.8.3
0.8.2
0.8.1
0.1.0
Design by Contract (DbC) for Elixir
Current section
Files
Jump to
Current section
Files
lib/bond/runtime/eval.ex
defmodule Bond.Runtime.Eval do
@moduledoc internal: true
require Logger
@moduledoc """
Internal helper module for runtime execution of contracts and assertions.
The runtime evaluation of a contract is split into two halves so that the work of building
the assertion-evaluation closure can be avoided when the runtime guard says skip:
1. `should_evaluate?/3` — read the runtime modes (see below) and return `false` when the
resolved mode for `kind` is exactly `false`. Also enforces the contract-checking chain
(`preconditions ≤ postconditions ≤ invariants`): if a lower kind is runtime-off, the
higher kind is also skipped, with a one-time `Logger.warning` per process per (higher,
lower) pair. Callers (the override clauses generated by
`Bond.Compiler.AnnotatedFunction`/`Bond.Compiler.Invariants` and the `Bond.check/1,2`
macro expansion) wrap the assertion-evaluation call in this check so the closure
capturing the call site's bindings is allocated only when we're actually going to
evaluate.
2. `evaluate_preconditions/1` / `evaluate_postconditions/1` / `evaluate_invariants/1` /
`evaluate_check/1` — enforce the Assertion Evaluation rule via a process-dict
recursion guard, invoke the supplied 0-arity fn, and on `{:assertion_failure, info}`
throw, raise the appropriate `Bond.PreconditionError` / `Bond.PostconditionError` /
`Bond.InvariantError` / `Bond.CheckError`.
## Runtime modes (the gate's fast path)
The per-kind runtime on/off state lives in a single `:persistent_term` entry — a map
`%{kind => true | false | :unset}` under the `:bond_runtime_modes` key — read once per
`should_evaluate?/3`. `:persistent_term` reads are lock-free and ~5x cheaper than the
`Application.get_env/3` this replaced, which mattered because the gate runs on every
contracted call (even when contracts are disabled) and the chain check multiplies the
lookups.
The term is **lazily seeded** from application env on first read: for each kind,
`Application.get_env(:bond, kind, :unset)`. So `config :bond, …` (config.exs) and
`config/runtime.exs` are both honoured — they're in app env by the time the first
contracted call runs. `:unset` (no global override) resolves to the call site's baked
`compile_default`, which carries per-module `:overrides`. The result is identical to the
old `Application.get_env(:bond, kind, compile_default)` semantics, but the app-env read
happens once instead of per call.
Runtime changes go through `Bond.Config` (which delegates to `put_mode/2` / `reset/0`
here). `Application.put_env(:bond, …)` *after* the first contracted call is no longer
picked up automatically — call `Bond.Config.reset/0` to re-seed from app env, or use the
`Bond.Config` setters. Writes go through `:persistent_term.put/erase`, which trigger a
global GC scan; this is fine for setup-time and occasional runtime toggles, not per-call.
Centralising the runtime policy here (both the "decide" and "act" halves) keeps all
runtime contract behaviour in the `Bond.Runtime` namespace and minimises the code injected
into using modules.
"""
@assertion_errors %{
precondition: Bond.PreconditionError,
postcondition: Bond.PostconditionError,
check: Bond.CheckError,
invariant: Bond.InvariantError
}
# Single persistent_term key holding the runtime modes map. Atom key (not a tuple) so the
# lookup hashes cheaply (~6 ns vs ~9 ns for a tuple key, ~30 ns for Application.get_env).
@modes_key :bond_runtime_modes
# Sentinel distinguishing "term never seeded" (trigger the lazy app-env read) from a
# seeded term whose entries may themselves be `:unset`.
@unseeded :__bond_modes_unseeded__
@kinds [:preconditions, :postconditions, :invariants, :checks]
@type assertion_fun :: (-> term())
@type compile_default :: boolean()
@type kind :: :preconditions | :postconditions | :checks | :invariants
# A kind's resolved mode in the runtime modes map: `true`/`false` for an explicit global
# setting (from app env at seed time, or from `Bond.Config`), or `:unset` meaning "no
# global override — fall back to the call site's baked `compile_default`".
@type runtime_mode :: boolean() | :unset
# A lower kind's compile-time default as it appears in the chain map. It can be `:purge` —
# not just a boolean — because a function with no `@pre`/`@post` of its own contributes
# `:purge` for that kind (e.g. an invariant-only `size/1` passes
# `%{preconditions: :purge, postconditions: :purge}`). `:purge` is treated as non-disabling
# (`:purge != false`).
@type chain_default :: boolean() | :purge
@doc """
Returns `true` when assertions of `kind` should be evaluated at this call site.
Reads the runtime modes term once (lazily seeding it from app env on first call) and
returns `false` only when the resolved mode for `kind` is exactly `false`. `compile_default`
is the per-module compile-time-resolved mode for the kind, baked into the generated call
site; it is used when the kind's runtime mode is `:unset` (no global override).
The third argument carries the compile-time defaults of every lower kind in the
contract-checking chain (`preconditions ≤ postconditions ≤ invariants`). When any of
those lower kinds resolves to `false`, the higher kind is also skipped — propagation
ensures a `:postconditions` failure isn't reported when `:preconditions` weren't even
checked. The first time propagation fires for a (higher, lower) pair within a process,
Bond emits a `Logger.warning` describing the skip.
For `:preconditions` (bottom of the chain) the map is empty; for `:postconditions` it
carries `:preconditions`; for `:invariants` it carries both. The `:checks` kind is
independent of the chain — its callers pass `%{}`.
"""
@spec should_evaluate?(kind(), compile_default(), %{optional(kind()) => chain_default()}) ::
boolean()
def should_evaluate?(kind, compile_default, chain_defaults \\ %{}) do
modes = modes()
if resolved_enabled?(modes, kind, compile_default) do
chain_clear?(modes, kind, chain_defaults)
else
false
end
end
@doc """
Returns the runtime modes map, lazily seeding it from application env on first read.
Public so `Bond.Config` can present the global runtime state; the generated call sites use
it via `should_evaluate?/3`.
"""
@spec modes() :: %{kind() => runtime_mode()}
def modes do
case :persistent_term.get(@modes_key, @unseeded) do
@unseeded -> seed_from_app_env()
modes -> modes
end
end
@doc """
Sets the runtime mode for `kind`, overriding both the app-env seed and the call site's
compile default until `reset/0`. Used by `Bond.Config`.
"""
@spec put_mode(kind(), boolean()) :: :ok
def put_mode(kind, value) when kind in @kinds and is_boolean(value) do
:persistent_term.put(@modes_key, Map.put(modes(), kind, value))
:ok
end
@doc """
Drops the runtime modes term so the next read re-seeds from current application env. Used
by `Bond.Config.reset/0` and test setup. Discards any `Bond.Config` overrides.
"""
@spec reset() :: :ok
def reset do
:persistent_term.erase(@modes_key)
:ok
end
# Builds the modes term from app env. `:unset` for kinds with no app-env entry, so they
# fall back to the call site's compile default. Idempotent under concurrent first reads:
# every caller computes the same term from the same app env.
defp seed_from_app_env do
modes = Map.new(@kinds, fn kind -> {kind, Application.get_env(:bond, kind, :unset)} end)
:persistent_term.put(@modes_key, modes)
modes
end
# Resolves whether `kind` is enabled given the modes term: an explicit `true`/`false`
# global setting wins; `:unset` falls back to the call site's `compile_default`. A missing
# key (shouldn't happen — the term always carries all kinds) also falls back.
defp resolved_enabled?(modes, kind, compile_default) do
case Map.get(modes, kind, :unset) do
:unset -> compile_default
value -> value
end != false
end
# Returns `true` when every lower-chain kind is enabled. Returns `false` as soon as one
# resolves to `false` — that's the propagation. The `higher_kind` is the kind that *wanted*
# to run; we pass it through so the propagation log can attribute the skip to a (higher,
# lower) pair. Resolution reuses the already-read `modes` term — no further lookups —
# falling back to each lower kind's compile default when its runtime mode is `:unset`.
#
# The empty-chain case (`:preconditions`, `:checks`) short-circuits, and the non-empty case
# walks the map with `:maps.iterator/1` rather than `Enum.reduce_while/3` — the Enumerable
# protocol dispatch and iterator machinery cost ~20-25 ns per call (even for an empty map),
# which dominated the gate once the per-kind `Application.get_env`s were gone.
defp chain_clear?(_modes, _higher_kind, chain_defaults) when map_size(chain_defaults) == 0,
do: true
defp chain_clear?(modes, higher_kind, chain_defaults) do
chain_clear(modes, higher_kind, :maps.next(:maps.iterator(chain_defaults)))
end
defp chain_clear(_modes, _higher_kind, :none), do: true
defp chain_clear(modes, higher_kind, {lower_kind, lower_default, iter}) do
if resolved_enabled?(modes, lower_kind, lower_default) do
chain_clear(modes, higher_kind, :maps.next(iter))
else
maybe_log_propagation(higher_kind, lower_kind)
false
end
end
# When propagation causes a higher kind to be skipped because a lower one is off,
# emit a one-time-per-process Logger warning. The dedup key is (higher, lower) per
# process — so a long-running OTP process logs at most one message per pair, but
# different processes (or processes restarted by their supervisors) get their own
# warnings. Tests reset the marker by running in fresh processes anyway.
defp maybe_log_propagation(higher_kind, lower_kind) do
key = {:__bond_propagation_logged__, higher_kind, lower_kind}
if Process.get(key) != true do
Process.put(key, true)
Logger.warning("""
Bond: :#{higher_kind} skipped because :bond, :#{lower_kind} is `false` at runtime.
The chain `preconditions ≤ postconditions ≤ invariants` requires that lower kinds be
enabled whenever higher kinds are. A :#{higher_kind} check is only meaningful if
:#{lower_kind} held first; with :#{lower_kind} disabled, :#{higher_kind} is also
skipped to avoid misleading errors.
To re-enable :#{higher_kind} evaluation, also enable :#{lower_kind}:
Bond.Config.enable(:#{lower_kind})
This warning is logged once per process per (higher, lower) pair.
""")
end
end
@doc """
Evaluates a single assertion result and throws an `:assertion_failure` if the result is
falsy. Returns `:ok` if the result is truthy.
Used as the building block for the lifted assertion defps emitted by
`Bond.Compiler.Assertion.assertions_body/2` and `invariants_body/2`. Centralising the
truthiness check here — where `result` is typed as `term()` at the function boundary —
prevents Dialyzer from proving the falsy branch unreachable when the caller's expression
is statically known to be `true` (e.g. `@pre is_binary(x)` on a function whose `@spec`
already narrows `x` to `binary()`). Inlining the same `if result do :ok else throw(...) end`
into the user's lifted defp emitted Pattern: `false`, Type: `true` warnings under
downstream `mix dialyzer`.
`assertion_info` is the map carrying the assertion's label/kind/file/line/etc. `binding_fun`
is a 0-arity thunk that returns the calling-function's `binding()` snapshot. It is a thunk
rather than an eager keyword list so the (potentially large) snapshot is built only on the
failure clauses — the success clause never forces it. The lifted defps emit
`fn -> binding() end` for it; see `Bond.Compiler.Assertion.assertions_body/2`.
"""
@spec check_assertion(term(), map(), (-> keyword())) :: :ok
def check_assertion(false, assertion_info, binding_fun),
do: assertion_failure(assertion_info, binding_fun.())
def check_assertion(nil, assertion_info, binding_fun),
do: assertion_failure(assertion_info, binding_fun.())
def check_assertion(_result, _assertion_info, _binding_fun), do: :ok
@doc """
Variant of `check_assertion/3` for `Bond.check/1` calls: returns the assertion's value on
success (so the `check expr` expression evaluates to `expr`'s value) rather than `:ok`.
Same Dialyzer-laundering motivation as `check_assertion/3`: clause-matching on `false`/`nil`
(rather than an `if` body) is what defeats Dialyzer's narrowing — a single-clause function
with `if result do ... else ... end` lets caller-inferred narrowing prove the falsy branch
dead even when the spec widens `result` to `term()`.
"""
@spec check_value(term(), map(), (-> keyword())) :: term()
def check_value(false, assertion_info, binding_fun),
do: assertion_failure(assertion_info, binding_fun.())
def check_value(nil, assertion_info, binding_fun),
do: assertion_failure(assertion_info, binding_fun.())
def check_value(value, _assertion_info, _binding_fun), do: value
@spec assertion_failure(map(), keyword()) :: no_return()
defp assertion_failure(assertion_info, binding) do
throw({:assertion_failure, Map.put(assertion_info, :binding, Enum.sort(binding))})
end
@spec evaluate_preconditions(assertion_fun()) :: term()
def evaluate_preconditions(preconditions_fun) when is_function(preconditions_fun, 0) do
evaluate_assertions(preconditions_fun)
end
@spec evaluate_postconditions(assertion_fun()) :: term()
def evaluate_postconditions(postconditions_fun) when is_function(postconditions_fun, 0) do
evaluate_assertions(postconditions_fun)
end
@spec evaluate_check(assertion_fun()) :: term()
def evaluate_check(check_fun) when is_function(check_fun, 0) do
evaluate_assertions(check_fun)
end
@spec evaluate_invariants(assertion_fun()) :: term()
def evaluate_invariants(invariants_fun) when is_function(invariants_fun, 0) do
evaluate_assertions(invariants_fun)
end
@doc """
Evaluates a protocol contract's assertions, attributing any failure to the protocol and the
implementation the call resolved to.
Used by the dispatch wrapper `Bond.Protocol` emits in a protocol module. Identical to
`evaluate_preconditions/1` / `evaluate_postconditions/1` (the kind comes from the assertion
itself), except that on failure the thrown `assertion_info` is enriched with
`:source_protocol` (the protocol module) and `:impl` (the resolved implementation module, via
`protocol.impl_for(subject)`, or `nil`). The impl is resolved only on the failure path, so an
enabled-but-passing contract pays nothing for it. `subject` is the value dispatch keyed on —
the protocol function's first argument.
"""
@spec evaluate_protocol_assertions(module(), term(), assertion_fun()) :: term()
def evaluate_protocol_assertions(protocol, subject, assertions_fun)
when is_atom(protocol) and is_function(assertions_fun, 0) do
evaluate_assertions(assertions_fun, fn assertion_info ->
assertion_info
|> Map.put(:source_protocol, protocol)
|> Map.put(:impl, resolve_impl(protocol, subject))
end)
end
# `impl_for/1` is generated on every protocol module (and survives consolidation); guard
# against the unexpected so diagnostics can never mask the original contract failure.
defp resolve_impl(protocol, subject) do
protocol.impl_for(subject)
rescue
_ -> nil
end
@doc """
Runs the lifted invariant check against a function's return value when that value carries
the struct the invariants are declared on.
Detects the `%module{}` and `{:ok, %module{}}` return shapes *here*, with `result` typed
as `term()`, rather than via a `case` spliced into the using module. A struct `case`
emitted into the user's module would let Elixir's type checker (1.18+) prove the struct
clauses unreachable for functions that return other shapes (e.g. a `size/1` returning an
integer), raising "the following clause will never match" — a warning that becomes a hard
error under `--warnings-as-errors`. Keeping the match in this separately-compiled,
`term()`-typed module avoids that while preserving identical runtime behaviour.
`check_fun` bridges back to the using module's private lifted-invariant defp. Any other
return shape falls through with no check.
"""
@spec check_struct_invariant(term(), module(), (struct() -> term())) :: :ok
def check_struct_invariant(result, module, check_fun)
when is_atom(module) and is_function(check_fun, 1) do
case extract_subject(result, module) do
{:ok, subject} -> evaluate_invariants(fn -> check_fun.(subject) end)
:error -> :ok
end
:ok
end
defp extract_subject(result, module) do
cond do
is_struct(result, module) -> {:ok, result}
match?({:ok, inner} when is_struct(inner, module), result) -> {:ok, elem(result, 1)}
true -> :error
end
end
# `enrich` is applied to the thrown `assertion_info` before the failure event and exception
# are built — the seam `evaluate_protocol_assertions/3` uses to attach protocol/impl info on
# the failure path only. Defaults to the identity for ordinary `@pre`/`@post`/`@invariant`.
defp evaluate_assertions(assertions_fun, enrich \\ &Function.identity/1) do
try do
with_recursion_check(assertions_fun)
catch
{:assertion_failure, %{kind: kind} = assertion_info} ->
assertion_info = enrich.(assertion_info)
emit_failure_event(assertion_info)
exception_module = Map.fetch!(@assertion_errors, kind)
exception = exception_module.exception(assertion_info)
# Strip Bond internal frames from the stacktrace so the failure points at the user's
# call site rather than into Bond.Runtime.Eval or the generated __bond_* defps.
:erlang.raise(:error, exception, prune_stacktrace(__STACKTRACE__))
end
end
# Fire `[:bond, :assertion, :failure]` whenever an assertion is violated. Pass the full
# `assertion_info` map as metadata so consumers can filter on `:kind`, attribute failures to
# a specific `:assertion_id`, or inspect the captured `:binding`. Measurements carry
# `:system_time` and `:monotonic_time` so consumers can use the event as a time-series
# signal without an additional clock read.
defp emit_failure_event(assertion_info) do
measurements = %{
system_time: System.system_time(),
monotonic_time: System.monotonic_time()
}
:telemetry.execute([:bond, :assertion, :failure], measurements, assertion_info)
end
defp prune_stacktrace(stacktrace) do
Enum.reject(stacktrace, &bond_frame?/1)
end
defp bond_frame?({module, fun, _arity_or_args, _location})
when is_atom(module) and is_atom(fun) do
bond_module?(module) or bond_generated_function?(fun)
end
defp bond_frame?(_), do: false
defp bond_module?(module) do
case Atom.to_string(module) do
"Elixir.Bond" -> true
"Elixir.Bond." <> _ -> true
_ -> false
end
end
# The lifted-closure defps emitted by `Bond.Compiler.AnnotatedFunction` live in the user's
# module but are Bond-generated. Hide them from stacktraces so failures point at the user's
# call site, consistent with the existing Bond.* frame pruning.
defp bond_generated_function?(fun) do
case Atom.to_string(fun) do
"__bond_" <> _ -> true
_ -> false
end
end
defp with_recursion_check(assertions_fun) do
# Mutually recursive contracts lead to infinite recursion, so don't evaluate assertions for a
# function if other assertions are already being evaluated in the current process.
#
# Assertion Evaluation rule (from Object-Oriented Software Construction):
# During the process of evaluating an assertion at run-time, routine calls shall
# be executed without any evaluation of the associated assertions.
if not evaluating_assertions?() do
set_evaluating_assertions(true)
try do
assertions_fun.()
after
set_evaluating_assertions(false)
end
end
end
defp evaluating_assertions? do
Process.get(:__bond_evaluating_assertions__, false)
end
defp set_evaluating_assertions(bool) do
Process.put(:__bond_evaluating_assertions__, bool)
end
end