Streaming follow-ups from the #3 review - #150
Conversation
- Frame-level decode errors now tear the connection down with
{:closed, {:error, reason}} instead of logging and continuing: after a
framing desync everything that follows is garbage. Malformed payload
JSON inside a well-framed message is still skipped. Covered by a new
raw gen_tcp WebSocket helper that can emit bytes a conformant server
cannot.
- Opt-in auto-reconnect: connect/2 accepts reconnect: true or
[initial_backoff:, max_backoff:, max_attempts:]; non-local drops
notify the subscriber with {:reconnecting, reason}, retry with
exponential backoff, replay the current subscription set (tracked
across runtime subscribe/unsubscribe), and send :reconnected.
close/1 stays terminal; exhausting max_attempts delivers
{:closed, reason} and exits. Messages skipped during a reconnect
handshake are re-queued instead of dropped.
- Facade coherence: Hunter.streaming_health?/2 delegates to
Hunter.Streaming.health?/2.
- README: add a Streaming section to Usage.
- Cosmetic: move the integration describe "streaming" block above the
private helpers; nginx CI config uses the map $http_upgrade idiom
(validated with nginx -t on the CI image).
Fixes #142
Co-Authored-By: Claude Fable 5 <[email protected]>
| with {:ok, state} <- establish(state), | ||
| {:ok, state} <- send_subscriptions(state) do | ||
| {:ok, state} | ||
| else | ||
| {:error, reason} -> {:error, reason} | ||
| {:error, _conn, reason} -> {:error, reason} | ||
| end |
There was a problem hiding this comment.
| with {:ok, state} <- establish(state), | |
| {:ok, state} <- send_subscriptions(state) do | |
| {:ok, state} | |
| else | |
| {:error, reason} -> {:error, reason} | |
| {:error, _conn, reason} -> {:error, reason} | |
| end | |
| with {:ok, state} <- establish(state) do | |
| send_subscriptions(state) | |
| end |
There was a problem hiding this comment.
Not applied, but for a reason that supersedes it: the review's leak finding means send_subscriptions/1 now returns {:error, state, reason} so callers can close the live connection. init/1 therefore can't pass its result through unchanged — it intercepts the error tuple, closes the conn via drop_connection/1, and returns {:error, reason} (06bf27a). The redundant with/else this suggestion targeted is gone either way.
|
@claude review once. |
|
Claude finished @milmazz's task in 4m 28s —— View job Review: Streaming follow-ups (#150)
Overall this closes out the five #141 follow-ups cleanly: the framing-desync teardown, opt-in reconnect, facade delegate, and cosmetic moves all match the PR description and read well. One real bug worth fixing before merge, plus a couple of smaller notes. Note on environment: I wasn't able to run Bug: leaked Mint connection on reconnect when handshake succeeds but resubscribe fails
def handle_info(:reconnect, %{conn: nil} = state) do
with {:ok, state} <- establish(state),
{:ok, state} <- send_subscriptions(state) do
send(state.subscriber, {:hunter_stream, self(), :reconnected})
{:noreply, %{state | attempts: 0}}
else
{:error, reason} -> retry_or_stop(state, reason)
end
endIf This is compounded by case send_frame(state, {:text, Poison.encode!(frame)}) do
{:ok, state} -> {:cont, {:ok, state}}
{:error, _state, reason} -> {:halt, {:error, reason}} # state (and its conn) is dropped here
end(This exact pattern also exists in Suggested fix: have Your own inline suggestion (init/1, line 58)Your suggested simplification: with {:ok, state} <- establish(state) do
send_subscriptions(state)
endis correct — Minor / non-blocking
Everything else — the framing-desync vs. malformed-JSON distinction, the |
From the PR review: in handle_info(:reconnect, ...) the with/else could
not see the state bound by establish/1, so a handshake that succeeded
but whose subscription replay failed leaked the just-opened connection
on every attempt (and init/1 had the same one-shot leak).
send_subscriptions/1 now returns {:error, state, reason} so both
callers can close the live connection via a shared drop_connection/1,
which disconnect/3 also reuses along with the backoff/2 helper instead
of pattern-matching initial_backoff by hand. New test covers close/1
during the backoff window.
Co-Authored-By: Claude Fable 5 <[email protected]>
|
All four findings addressed in 06bf27a:
No test asserts the absence of the leak itself — there's no deterministic wire-level way to make the upgrade succeed and the first subscription frame fail (a server-side FIN still lets the client's first send succeed), so that fix is verified by inspection plus the suite. Verification: |
Co-Authored-By: Claude Fable 5 <[email protected]>
Fixes #142 — all five follow-up items deferred from the final review of #141.
Framing-desync close policy
A frame-level
Mint.WebSocket.decode/2error now tears the connection down with{:closed, {:error, reason}}instead of logging and continuing: once the byte stream is desynced, everything after the bad frame is garbage. Malformed payload JSON inside a well-framed message keeps the spec'd skip behavior. Tested via a newHunter.RawStreamingServer(bare:gen_tcp+ manual upgrade handshake) that can emit framing bytes a conformant WebSock server cannot.Opt-in auto-reconnect
Hunter.Streaming.connect/2acceptsreconnect: trueor[initial_backoff:, max_backoff:, max_attempts:](defaults 1s / 30s /:infinity). When an established connection drops for any non-local reason, the subscriber receives{:hunter_stream, pid, {:reconnecting, reason}}, the process retries with doubling backoff, replays the current subscription set (tracked across runtimesubscribe/3/unsubscribe/3), and sends:reconnected. Non-breaking:falsewith the v1 notify-and-exit behavior{:error, reason}on failureclose/1stays terminal; exhaustingmax_attemptsdelivers{:closed, reason}and exits:normalHandshake
receivein the reconnect path re-queues unrelated messages (e.g.$gen_call) instead of silently eating them.Facade coherence
Hunter.streaming_health?/2now delegates toHunter.Streaming.health?/2.README
Usage gains a short Streaming section (connect, event shape, runtime control, reconnect pointer).
Cosmetic
describe "streaming"block moved above the private helpers intest/integration/mastodon_test.exsmap $http_upgrade $connection_upgradeidiom; validated withnginx -ton thenginx:1.27-alpineCI imageVerification
mix test— 2 doctests, 251 tests, 0 failures (5 new streaming tests: framing teardown, facade delegate, reconnect+resubscribe, runtime-set replay, terminal close, max_attempts give-up)mix credo --strict,mix dialyzer,mix docs,mix format --check-formattedall clean🤖 Generated with Claude Code