Mob.Invariant (mob v0.8.2)

Copy Markdown View Source

Checks the framework can make about itself, and the rule that stops them becoming noise.

An application cannot assert that a component's owning screen is still alive, or that no dead screen is sitting in the navigation stack — it does not have the handles. The framework does, each check is a few microseconds, and each one guards a class of bug that recent releases kept re-fixing.

A violation must survive to the next sample

This is the whole design, not a refinement. Every check here reads live state from processes that are concurrently changing: a screen mid-teardown has a dead pid and components that have not yet been reaped, and a check sampling that instant sees a violation that resolves itself shortly afterwards. Reporting it produces a defect nobody can reproduce, which is worse than reporting nothing — it teaches the reader to ignore the channel.

So the first sighting of a violation is held as a candidate, and it is recorded only when two things are true: the same violation is still there at the next sampling of that point, and the candidate is at least 50ms old.

Both halves are needed, and the second was not obvious. Sampling points are event-driven — the router stops screens in a tight loop, so during a multi-screen reset "the next sample" can arrive in under a millisecond, and a component still being reaped is seen twice. Surviving one teardown is therefore not proof of anything; surviving 50ms is, because a real leak persists indefinitely and does not notice the wait. With deferral alone, healthy teardowns still produced about one confirmed violation in sixty.

The floor is :mob, :invariant_min_candidate_age_us for a device whose teardown outlasts the default.

The first version of this re-ran the check immediately instead, back to back in the same process. That was measured and it filtered nothing: the gap between the two calls is about a microsecond and the transients it was meant to catch last tens to hundreds, so it suppressed ~0% of them while reporting a confirmed :critical on healthy teardowns. Two evaluations a microsecond apart cannot disagree, which made the rule an assertion about nothing.

Sameness is by fingerprint over the violation's details, so a different transient at the next sample does not confirm the first one.

That makes the checks themselves a contract: a check must be deterministic over stable state, and its details must identify the violation rather than describe the moment. One that samples something genuinely time-varying — a timestamp, a queue length — cannot be expressed here, and should not be.

It ships in release builds

Per decisions/2026-09-04-defect-reports-are-a-shipped-feature.md, the interesting failures happen where no agent is watching. That makes the cost real rather than theoretical, so it is budgeted rather than discovered on someone's three-year-old Android: see cost_us/2, and the numbers in decisions/2026-09-10-an-invariant-must-survive-to-the-next-sample.md.

Registering

Mob.Invariant.register(:my_check,
  at: :on_screen_stop,
  severity: :critical,
  check: fn context -> ... end
)

A check returns :ok, {:violation, details}, or {:violations, [details]} where each details is a map carrying no application state — the same rule receipts follow. Pids, module names and counts are fine; assigns are not.

Report independent problems separately. A check that finds three leaked components should return three violations, not one carrying a list. Each matures on its own; rolled into one, the details change whenever any of them does, the fingerprint changes with it, and nothing ever confirms.

Summary

Functions

Microseconds to run every check registered for point once, measured now.

Register a check.

Every check registered for point.

Run every check registered for point against context.

Forget a check.

How many violations are held.

The violations held, newest first.

Types

context()

@type context() :: map()

point()

@type point() :: :after_committed_frame | :on_screen_stop | :periodic

result()

@type result() :: :ok | {:violation, map()} | {:violations, [map()]}

severity()

@type severity() :: :critical | :warning

Functions

cost_us(point, context \\ %{})

@spec cost_us(point(), context()) :: non_neg_integer()

Microseconds to run every check registered for point once, measured now.

For budgeting on the device that matters rather than on a laptop. Runs the checks for real, so it observes whatever the app is currently doing.

This is one pass of each check. A sampling point also does candidate bookkeeping and, on confirmation, a record — so a real sample costs somewhat more than this reports. The table in the decision record is measured through run/2 and is the number to budget against.

register(name, opts)

@spec register(
  atom(),
  keyword()
) :: :ok

Register a check.

Re-registering the same name replaces the previous definition, so a hot code push does not accumulate duplicates.

registered(point)

@spec registered(point()) :: [map()]

Every check registered for point.

run(point, context \\ %{})

@spec run(point(), context()) :: [Mob.Invariant.Violation.t()]

Run every check registered for point against context.

Returns the violations confirmed by this run: ones also seen at the previous sampling of point and whose candidacy is at least :mob, :invariant_min_candidate_age_us old (50ms by default). A violation seen for the first time, or too recently, is held as a candidate and returns nothing. Never raises: a check that blows up is itself reported as a violation of :invariant_check_failed rather than being allowed to take down the process that was kind enough to sample.

unregister(name)

@spec unregister(atom()) :: :ok

Forget a check.

violation_count()

@spec violation_count() :: non_neg_integer()

How many violations are held.

violations(limit \\ 20)

@spec violations(pos_integer()) :: [Mob.Invariant.Violation.t()]

The violations held, newest first.