Firebreak.ModuleInfo (Firebreak v0.1.0)

Copy Markdown View Source

Everything we learned about a single module: what kind of OTP citizen it is, its supervision configuration (if a supervisor), the names it registers itself under, and its outgoing coupling edges.

The supervision-tree fields (strategy, max_restarts, max_seconds, children) can come from two places, recorded in tree_source:

  • :exact — read at runtime by calling Mod.init/1, the same call OTP's supervisor makes. Authoritative: resolves child lists assembled dynamically (pipes, conditionals, builder calls) that no static pass can.
  • :static — recovered from the AST without running the code. Best-effort; a child spec or option assembled at runtime is reported as unresolved rather than guessed.

The coupling fields (edges, registers, unsupervised_spawns, traps_exit?) are always static — they describe who-calls-whom, which init/1 does not reveal.

Dynamic children

A DynamicSupervisor returns no children from init/1 — they are added at runtime via DynamicSupervisor.start_child/2, so the static (and even exact) tree shows the supervisor as empty. Two fields recover that runtime shape statically:

  • dynamic_starts — every start_child call site found in this module, as {sup_target, child} pairs (the supervisor the call targets and the best-effort Child parsed from the spec). The call may target a supervisor defined in a different module.
  • dynamic_children — resolved in Firebreak.Coupling: the dynamic children attributed to this module as their supervisor (gathered from every module's dynamic_starts whose sup_target resolves here). Best-effort.

Supervision evidence (orphan-check inputs)

Two fields feed the orphan check the evidence that a stateful process which appears in no supervisor's subtree is nonetheless meant to be (or is) started a particular way — so the finding can be relabelled or sharpened rather than left as a vague "we couldn't see it":

  • child_spec_builds — modules this one builds a child spec for (Mod.child_spec(...)), scanned from the full body including quote blocks, since a child_spec call inside a __using__ macro is exactly how a base module declares that its users will supervise Mod. A module that something builds a child spec for is supervised through an indirection we can't attribute to a specific supervisor — not an orphan.
  • manual_starts{module, line} for every direct Mod.start_link(...) call site in this module (excluding the OTP behaviour helpers and self). A stateful module started this way, outside any supervisor, genuinely won't be restarted on crash — the orphan finding can name the exact call site.

Wrapper-call coupling (inter-procedural inputs)

Most apps don't litter GenServer.call(Server, …) across the codebase; they expose a public API (Server.fetch(id)) that wraps the call. A naive edge scan only sees the direct call shapes, so it misses the dependency from the API caller onto the server process. Two fields recover the first level of that:

  • api_entries — this module's public functions that themselves perform a coupling call, as fun => [{kind, target, sync?}]. A self-call (GenServer.cast(__MODULE__, …) in def notify/1) is recorded with this module as the target, so a caller of notify/1 can be wired to it. OTP callbacks (handle_call, init, …) are excluded — they aren't an external API.
  • api_calls{module, fun, in_init?, line} for every remote call Mod.fun(...) this module makes. Firebreak.Coupling keeps only those that match some module's api_entries and synthesises the resulting edge onto the wrapped process.
  • api_fun_callspublic_fun => [{module, fun}]: the remote calls each public function makes. This lets Firebreak.Coupling chain wrappers transitively (A calls B.f, B.f calls C.g, C.g couples to a process ⇒ A depends on it), bounded by a depth cap and cycle-guarded. Only chains through remote public-function calls, not private helpers.

Concurrency evidence

  • lookup_or_create — functions performing a non-atomic registry test-and-set: a single body that both reads the registry (whereis / Registry.lookup / :global.whereis_name / registered) and creates a registration (register / start_link / start_child / spawn). {fun, line, mechanism} per such function; mechanism is :register_raises (a duplicate register crashes the loser) or :register_soft (a duplicate start returns {:error, {:already_started, _}}). The classic lookup-or-create race (Christakis & Sagonas, PADL 2010): two callers both miss the read and both create, and the loser's just-spawned process leaks as a ghost.
  • opens_port — lines at which this module opens a port (Port.open / :erlang.open_port), i.e. bridges to an external OS process.
  • handles_port_exit — whether any handle_info clause handles a port's termination (a head mentioning :exit_status / :EXIT / :eof). A module that opens a port but never handles its exit can't notice the external program dying (or is taken down by the linked exit).

Summary

Types

How a start_child call names its supervisor: a literal module (__MODULE__ is pre-resolved to the enclosing module), a registered name to look up in the global name index, or unresolvable.

t()

Functions

Default restart intensity for a supervisor when not explicitly set.

Was the supervision tree read at runtime (exact) rather than parsed (static)?

Does this module hold process state worth losing on restart? GenStage stages carry buffered demand/events, so they count alongside GenServer/gen_statem/Agent.

Is this module a supervisor of any flavour? Broadway counts: it boots an internal supervision subtree (producers/processors/batchers), so its declared producer is a real subtree member for blast-radius purposes.

Types

dynamic_target()

@type dynamic_target() :: {:module, module()} | {:name, term()} | :unknown

How a start_child call names its supervisor: a literal module (__MODULE__ is pre-resolved to the enclosing module), a registered name to look up in the global name index, or unresolvable.

kind()

@type kind() ::
  :supervisor
  | :dynamic_supervisor
  | :genserver
  | :gen_statem
  | :gen_stage
  | :broadway
  | :application
  | :task
  | :agent
  | :other

t()

@type t() :: %Firebreak.ModuleInfo{
  api_calls: [{module(), atom(), boolean(), non_neg_integer() | nil}],
  api_entries: %{
    optional(atom()) => [
      {Firebreak.Edge.kind(), Firebreak.Edge.target(), boolean()}
    ]
  },
  api_fun_calls: %{optional(atom()) => [{module(), atom()}]},
  behaviours: [module()],
  callback_starts: [{module(), atom(), non_neg_integer() | nil}],
  child_spec_builds: [module()],
  children: [Firebreak.Child.t()],
  dynamic_children: [Firebreak.Child.t()],
  dynamic_start?: boolean(),
  dynamic_starts: [{dynamic_target(), Firebreak.Child.t() | nil}],
  edges: [Firebreak.Edge.t()],
  ets_tables: [term()],
  file: String.t() | nil,
  handles_port_exit: boolean(),
  init_arg_struct: module() | nil,
  kind: kind(),
  line: non_neg_integer() | nil,
  lookup_or_create: [
    {atom(), non_neg_integer() | nil, :register_raises | :register_soft}
  ],
  manual_starts: [{module(), non_neg_integer() | nil}],
  max_restarts: non_neg_integer() | nil,
  max_seconds: non_neg_integer() | nil,
  name: module(),
  opens_port: [non_neg_integer() | nil],
  own_restart: Firebreak.Child.restart(),
  own_shutdown: term(),
  registers: [term()],
  strategy: atom() | nil,
  traps_exit?: boolean(),
  tree_source: tree_source(),
  unsupervised_spawns: [{atom(), non_neg_integer() | nil}],
  uses: [module()]
}

tree_source()

@type tree_source() :: :static | :exact

Functions

effective_max_restarts(module_info)

Default restart intensity for a supervisor when not explicitly set.

effective_max_seconds(module_info)

exact?(module_info)

Was the supervision tree read at runtime (exact) rather than parsed (static)?

stateful?(module_info)

Does this module hold process state worth losing on restart? GenStage stages carry buffered demand/events, so they count alongside GenServer/gen_statem/Agent.

supervisor?(module_info)

Is this module a supervisor of any flavour? Broadway counts: it boots an internal supervision subtree (producers/processors/batchers), so its declared producer is a real subtree member for blast-radius purposes.