Finds a long-lived process that links a child it starts itself and never traps exits.
The defect this exists for
A feed process must survive the death of any process it links. Found live in four
of five venue packages on 2026-09-07: Feed.init/1 never called Process.flag(:trap_exit, true), and Socket.start_link/1 (or PollingFeed.start_link/1) ran from inside a
Feed callback — handle_call/3, handle_info/2, or a {:continue, _} reached from
either — which links the child to Feed itself, not to a supervisor. An abnormal exit
on that link is untrappable without the flag, so a socket dying anywhere killed Feed,
and the Supervisor restarted it from its static start opts — every subscribe/2
a consumer had made since boot, gone in the same instant.
Fixed identically in all five repos, by calling Process.flag(:trap_exit, true) as the
first thing init/1 does: dp_exchange_coinbase e77b542, dp_exchange_gemini
66acd3b, dp_exchange_webull d0c54a8, dp_exchange_schwab 90dddc6,
dp_exchange_robinhood 51ad189. Five independent fixes converging on the identical
one-line remedy is the strongest possible sign that the remedy, not a runtime
simulation of the crash, is what a static check should look for.
Why this is static, not behavioural
The obvious check is behavioural: start the package's real tree, read the feed's
Process.info(pid, :links), kill one, assert the feed survives. It was tried first and
rejected, for a reason specific to this family rather than a general objection to
behavioural tests: starting a venue's real (non-fake) tree is not reliably
network-free.
Four of five venues defer any socket or poll to subscribe/2/update_symbols/2 — proven
by reading init/1 in each: Coinbase, Gemini and Webull return {:ok, state} with
no {:continue, _}, and Robinhood's PollingFeed schedules its first fetch
@default_start_delay_ms (8s) out rather than firing one immediately. dp_exchange_schwab
does not: Feed.init/1 returns {:ok, state, {:continue, :connect}}, and
handle_continue(:connect, state) unconditionally calls ensure_route/1, which — absent
an already-open state.socket — calls Rest.get_user_preference/2, a real HTTP request
to Schwab's live API, regardless of whether any symbol has ever been subscribed.
Starting DpExchange.Schwab's supervision tree with nothing but child_spec/1 therefore
dials the network today, which is exactly what a suite every consumer runs on every CI
run must never do (CLAUDE.md's testing strategy, tier 1).
The one way to prevent that dial without starting the real socket is to inject an
already-open stand-in through an option each venue happens to expose for its own tests
(:socket here, differently named or absent elsewhere) — which is exactly what this
suite's own moduledoc forbids: "No assertion may name a socket, a channel string, a
transport module or a polling interval. If one does, mechanism has leaked through the
facade and the assertion is the bug, not the venue." A behavioural check that only
works by knowing a venue's private test-injection option name is that bug.
There is also no reliable, generic way to find "the feed" once a tree is running.
DpExchange.Core.FeedBehaviour exists for exactly this and is implemented by none of
the five venues — grepped, zero adopters. Walking child_spec/1's supervision tree for
worker children finds every long-lived process a venue starts (a rate limiter, a task
supervisor, a feed), not specifically the one this invariant is about.
So: a behavioural version of this check would need to risk a live network call on at least one shipped venue, or lean on a naming convention the contract explicitly forbids, or both. This is the "narrower thing that is [mechanically enforceable]" — it proves nothing was ever started, checks source-derived fact rather than simulated behaviour, and cannot dial out because nothing here ever runs.
One half of that reasoning expired the same day it was written
The network-call objection was true of dp_exchange_schwab, whose Feed.init/1 returned
{:ok, state, {:continue, :connect}} and dialled the Streamer before anything had
subscribed. That was itself a boundary violation — the family's rule is that a
consumer who has not asked for a venue must not find a socket open — and it was fixed
hours later, in dp_exchange_schwab 9973c0d. All five venues now defer every dial to
subscribe/2 or a first tick, so starting a tree touches no network.
The second objection stands unchanged: there is still no contract-sanctioned way to
locate "the feed" generically. Core.FeedBehaviour has zero adopters, and walking
child_spec/1's tree finds every long-lived child rather than the one this invariant is
about. So a behavioural check is now possible but still not clean, and it is recorded
here rather than built, so the next person weighing it starts from what is actually true.
What a static check cannot see, stated so nobody over-trusts it
This proves the guard is present, not that a crash is isolated. A module can trap
exits and still handle the resulting {:EXIT, _, _} wrongly. That is not hypothetical:
dp_exchange_webull already had trap_exit and per-shard isolation on 2026-09-07 and
still left coverage/1 reporting a crashed shard's symbols as :stream — a real
defect this assertion would have passed. Whether coverage tells the truth after a child
dies is a per-venue invariant Core cannot express, because Core does not know which
symbols a given child carried. It belongs in each venue's own suite, and every venue has
such a test as of that date.
What it checks
For every module compiled from this package's own lib/ that declares one of
GenServer, :gen_statem, GenStateMachine or WebSockex as a @behaviour — every
shape this family's Feed and Socket modules are actually built from — the module's
own compiled abstract code is scanned, in full, for two facts:
- a link-creating call: a remote call named
start_link(any target module, any arity —Socket.start_link/1,PollingFeed.start_link/1,Task.Supervisor. start_link/1, all match, becauseX.start_linkalways links the calling process to the one it starts, by the same OTP convention this whole family builds on), orProcess.link/1/:erlang.link/1, orspawn_linkin any arity. - the guard: a call to
:erlang.process_flag(:trap_exit, true)— whatProcess. flag(:trap_exit, true)compiles to (verified against a compiled probe module: it is a plain remote call, with:trap_exitandtrueas literal atom arguments, not a macro or anything special-formed).
A module with the first and not the second is a violation: it manufactures a link to a process it started and has no way to survive that process dying abnormally. A module with neither is not a candidate at all — most process behaviours in a venue package start nothing themselves and have nothing to protect against.
This deliberately does not require the link-creating call to be reachable specifically
from init/1, or from any single named callback. The five real fixes disagree on where
they call it and where they open sockets from (Coinbase and Schwab open lazily from a
callback other than init/1; Gemini and Robinhood link something directly inside
init/1) — the invariant that matters is "this module, considered as a whole, both
manufactures a link and protects itself," not which specific function does which.
start_link/1, start_link/2, child_spec/1 and child_spec/2 are never scanned as
the SOURCE of a link-creating call — found live running this check against the real,
compiled dp_exchange_coinbase: Socket.start_link/1 (use WebSockex) delegates to
WebSockex.start_link/4 to bootstrap itself, which is a call literally named
start_link, on every process-behaviour module in the family, always. That link
belongs to whoever CALLS Socket.start_link/1 — a Feed callback, in production —
not to Socket, and Feed is exactly the module this check is supposed to flag (and
does, correctly, once its own code rather than Socket's bootstrap wrapper is what is
under scan). DpExchange.Core.UnwiredCheck already excuses the same two names for an
analogous reason (@always_excluded); this reuses that precedent rather than
reinventing it.
What this does not catch
It does not prove the module handles the resulting {:EXIT, pid, reason} message
correctly — clearing coverage, firing a :link_down notice, reopening the child. A
GenServer that traps exits but defines no handle_info/2 for the exit tuple still
survives (use GenServer injects a default handle_info/2 that logs and continues,
proven by inspecting a compiled probe module's abstract code), which is the literal claim
this check makes — "the feed process must survive" — and no more. Whether it recovers
usefully is a richer behaviour, is genuinely different per venue (which notice, which
state gets cleared, whether it reopens immediately or on the next tick), and is exactly
the kind of venue-specific mechanism this contract's own moduledoc forbids an assertion
from encoding.
It also does not catch a link created any other way than the four call shapes above — a
link established through a NIF, a port, or code assembled at runtime and dispatched via
apply/3 with a dynamically built function name. No static tool resolves genuinely
dynamic dispatch, the same limit DpExchange.Core.UnwiredCheck's moduledoc already
states for the same reason.
Summary
Types
@type violation() :: %{ module: module(), function: atom(), arity: non_neg_integer(), file: String.t() | nil, line: pos_integer() | nil }
Functions
Renders run/2 violations as one line per finding, for a failure message.
Finds every process-behaviour module under lib_root that creates a link without
trapping exits.
beam_dir is a directory of compiled .beam files (typically
Mix.Project.build_path() |> Path.join("lib/#{app}/ebin")), the same one
DpExchange.Core.UnwiredCheck.run/3 reads. Only modules whose recorded :compile_info
source path falls under lib_root are analysed, whichever Mix environment produced the
.beam files.