LetItCrash.Async (let_it_crash v0.5.0)
View SourceTest helpers for async work — fire-and-forget Tasks, Oban jobs, LiveView
handle_async/3 callbacks, and other process-spawning work that runs
outside the caller's stack frame.
LetItCrash.Async complements the supervision-focused helpers in
LetItCrash (crash/2, recovered?/2,3, assert_supervision_impact/3).
Where those test how the supervision tree reacts to crashes, this module
tests three async failure modes that aren't visible from the caller:
Three async failure modes
1. Silent swallow
A Task raises but no one is awaiting it. The supervisor moves on; the
test passes; no log catches the human's eye. Detected via
:task.exception (and similar) telemetry events:
report =
LetItCrash.Async.observe_async(fn ->
Task.start(fn -> raise "boom" end)
end)
{:error, {:silent_swallow, _}} = LetItCrash.Async.assert_no_silent_swallow(report)2. Partial state / lost result
The block finishes but spawned work hasn't completed within the user's budget. Detected via wall-clock comparison:
report = LetItCrash.Async.observe_async(fn -> spawn_slow_task() end)
:ok = LetItCrash.Async.assert_all_completed(report, within: 1000)3. Non-idempotent retry
A user-visible operation produces different state when re-executed. Detected by running the function twice and snapshotting state:
:ok = LetItCrash.Async.assert_idempotent(
fn -> MyApp.do_work() end,
state: &MyApp.snapshot/0
)When to use this vs the supervisor helpers
Use LetItCrash.crash/2 + LetItCrash.assert_supervision_impact/3 when
the question is "does my supervision strategy match what I intend?". Use
LetItCrash.Async when the question is "does my async work actually
finish, and does it crash loudly when it fails?".
Ecto.Sandbox interaction
A Task spawned inside an observe_async/2 block may need to inherit the
caller's sandboxed connection. v0.5.0 does NOT automate this. Pass the
:sandbox opt to document your test's choice:
LetItCrash.Async.observe_async(
fn -> ... end,
sandbox: :inherit # default; call Ecto.Adapters.SQL.Sandbox.allow/3 explicitly when needed
)Limitations (v0.5.0)
- No telemetry from raw
Task. Pure ElixirTask(whetherTask.start/1,Task.async/1, orTask.Supervisor.async_nolink/3) does NOT emit[:task, :exception]events today. We subscribe to that event name speculatively, but in practice the events that reach the observer come from Oban ([:oban, :job, :exception]) and Phoenix LiveView ([:phoenix, :live_view, :handle_async, :exception]). To detect a raw Task swallow you currently need to either install a telemetry shim that emits[:task, :exception]or useassert_all_completed/2to bound the work's wall-clock duration. - No
Loggercapture. Tasks that log errors but recover gracefully are currently considered "completed normally". - Broadway / GenStage are not yet observed.
- Process-tree tracing is not used. Only telemetry-emitting work is
visible. Raw
spawn/1without telemetry will not show up in the Report's:exceptionslist. $callerslineage gate. To isolate concurrent observers, the handler forwards an event only when the emitting process is the observer's owner OR has the owner in its:"$callers"process dictionary.Task.async/1,Task.start/1, andTask.Supervisor.async_nolink/3all copy$callersautomatically, so Task-spawned work works as expected. However, rawspawn/1(andProcess.spawn/1) do NOT copy$callers. A third-party library that wrapsspawn/1and emits[:task, :exception]would be silently filtered out even though it does emit telemetry.
See LetItCrash.Async.Report for the data structure produced by
observe_async/1,2.
Summary
Functions
Asserts that the observed block finished within within: milliseconds and
that every spawned process either completed or crashed (no still-running
work at block exit).
Asserts that calling fun twice in succession leaves the observed state
unchanged between the two runs.
Asserts that no exception was silently swallowed during the observed block.
Runs fun and returns a %LetItCrash.Async.Report{} describing the async
work observed during the call.
Functions
@spec assert_all_completed( LetItCrash.Async.Report.t(), keyword() ) :: :ok | {:error, term()}
Asserts that the observed block finished within within: milliseconds and
that every spawned process either completed or crashed (no still-running
work at block exit).
Options
:within(required) — wall-clock budget in milliseconds; compared againstreport.duration_ms
Returns
:ok— under budget and no in-flight work remains{:error, {:exceeded_within, %{duration_ms: n, budget_ms: w}}}— the block took longer thanwithin{:error, {:incomplete, list}}— one or more spawned pids never reached either:completedor:crashed(reserved for when the observer can track that — currently always returns:okfor the no-pid-tracking case)
Examples
report = LetItCrash.Async.observe_async(fn ->
Task.async(fn -> :timer.sleep(50) end) |> Task.await()
end)
assert :ok = LetItCrash.Async.assert_all_completed(report, within: 500)
Asserts that calling fun twice in succession leaves the observed state
unchanged between the two runs.
Idempotency requires re-execution by definition, so this assertion takes a
0-arity function (not a Report). The user supplies a :state 0-arity
function that returns a snapshot of whatever state is relevant — e.g.
the count of rows in a DB table, the contents of an ETS table, the value
in an Agent. The snapshots must be comparable with ==.
The flow:
- Run
funonce. - Snapshot state — call this
after_first. - Run
funagain. - Snapshot state — call this
after_second. - Assert
after_first == after_second.
Only the snapshots after each run are compared; an initial pre-run snapshot would distinguish a no-op fn from a side-effecting one, but that's a different property (purity, not idempotency) and is left for the caller to assert separately if needed.
Options
:state(required) — 0-arity function returning the state snapshot
Returns
:ok—funis idempotent{:error, {:state_changed, %{after_first: a, after_second: b}}}— runningfuna second time changed the snapshot
Examples
:ok = LetItCrash.Async.assert_idempotent(
fn -> Map.put(%{}, :a, 1) end,
state: fn -> :no_persistent_state end
)
@spec assert_no_silent_swallow( LetItCrash.Async.Report.t(), keyword() ) :: :ok | {:error, term()}
Asserts that no exception was silently swallowed during the observed block.
A "silent swallow" is the presence of any exception-shaped telemetry event
(:task.exception, :oban.job.exception, :phoenix.live_view.handle_async.exception)
inside the block. If an exception had propagated to the test process, the
test would have already failed — so the only way exceptions reach this
Report is by being absorbed by a non-linked Task, a retried Oban job, or
a swallowed LiveView async.
Returns
:ok— no exceptions seen{:error, {:silent_swallow, list}}— one or more exception events were captured;listis the list of{event, measurements, metadata}tuples
Examples
report = LetItCrash.Async.observe_async(fn ->
Task.start(fn -> raise "boom" end)
Process.sleep(50)
end)
{:error, {:silent_swallow, [{[:task, :exception], _, _}]}} =
LetItCrash.Async.assert_no_silent_swallow(report)
@spec observe_async( (-> any()), keyword() ) :: LetItCrash.Async.Report.t()
Runs fun and returns a %LetItCrash.Async.Report{} describing the async
work observed during the call.
Telemetry handlers are attached on entry and detached on exit, even when
fun raises. If fun raises, the exception is re-raised after the
observer cleans up — the Report is computed for cleanup purposes but is
not returned in that path.
Options
:observe— list of telemetry event names to subscribe to. Defaults to the standard Task/Oban/LiveView exception events.:sandbox—:inherit(default) or:off. Documentation-only in v0.5.0; does not change behavior.
Examples
# An Oban job that raises is caught by Oban's perform wrapper, which
# emits `[:oban, :job, :exception]`. `observe_async/1` captures it.
report = LetItCrash.Async.observe_async(fn ->
:telemetry.execute(
[:oban, :job, :exception],
%{duration: 1},
%{worker: "MyWorker", kind: :error, reason: %RuntimeError{message: "boom"}}
)
end)
assert {:error, {:silent_swallow, _}} =
LetItCrash.Async.assert_no_silent_swallow(report)Pure Elixir Task.start/1 + raise does NOT emit any telemetry today, so
it will NOT show up in the report — see "Limitations" above. For that case
use assert_all_completed/2 to bound the wall-clock duration instead.