Cleanup in terminate/2 that will not run when it matters.
Another contract nobody writes down. Putting cleanup in terminate/2
looks like saying "run this on the way out". OTP's actual rule is
narrower, and the gap is silent:
terminate/2 runs when a callback returns {:stop, ...}, or raises. On a
supervisor shutdown — which is how processes normally stop, and the
case the cleanup was written for — the parent sends an exit signal, and a
process that is not trapping exits simply dies. terminate/2 is never
called, nothing is logged, and the buffer is not flushed.
This is documented GenServer behaviour and still one of the most
reliably-made mistakes on the BEAM, because the code reads correctly and
the tests pass: a test calling GenServer.stop/1 exercises the path that
does run terminate, so the one path that matters in production is the
one never exercised.
A second obligation applies once you are trapping: terminate/2 must
finish inside the child's shutdown timeout (5000 ms by default) or the
supervisor brutal-kills it and the cleanup is truncated anyway.
Scope
Writes only, and not logging. A terminate/2 that logs "shutting down"
loses nothing when skipped; one that flushes a buffer, releases a lease,
or tells another system it is going away loses something real. That
distinction comes from the mode dimension in Argus.Purity.Effects —
the same one that keeps config reads out of the transaction analysis.