-module(roadrunner_loop_response).
-moduledoc false.
%% Per-connection `{loop, ...}` response — message-driven streaming.
%%
%% Called by `roadrunner_conn_loop:dispatch_response/4` after a handler
%% returns `{loop, Status, Headers, State}`. Writes the status line +
%% chunked headers, then runs a recursive selective-receive loop that
%% dispatches every Erlang message through `Module:handle_info/3`. The
%% handler's `Push(Data)` callback frames the data as one chunk and
%% writes it. On `{stop, _NewState}` the loop emits the size-0 chunked
%% terminator and returns.
%%
%% **Runs in the conn process**, not a child — handlers commonly do
%% `self() ! Msg` or `register(Name, self())` from `handle/1`,
%% expecting the loop to share their mailbox. Splitting into a child
%% process would break that contract; the loop stays inline.
%%
%% ## Mailbox contract
%%
%% The conn is a plain `proc_lib`-spawned loop, not a `gen_*` behaviour,
%% so it doesn't speak the OTP `sys` / `gen_call` / `gen_cast` protocols.
%% The receive selectively skips those shapes (`{system, _, _}`,
%% `{'$gen_call', _, _}`, `{'$gen_cast', _}`) so a misuse like
%% `gen_server:call(ConnPid, _)` doesn't accidentally surface as an
%% `handle_info/3` event to the user handler. Concretely:
%%
%% - `sys:get_state/1`, `sys:trace/2`, `gen_server:call/2,3` and
%% friends against the conn process will appear to hang — the
%% caller should expect to time out.
%% - Any other Erlang message reaches the handler's `handle_info/3`
%% verbatim. Handlers should pattern-match defensively (with a
%% catch-all clause) rather than crash on unexpected messages.
-export([run/5]).
-doc """
Send the chunked-response head, then enter the message-receive
loop. Returns when the handler's `handle_info/3` returns `{stop, _}`.
""".
-spec run(
roadrunner_transport:socket(),
roadrunner_http:status(),
roadrunner_http:headers(),
module(),
term()
) -> ok.
run(Socket, Status, UserHeaders, Handler, State) ->
Headers = [{~"transfer-encoding", ~"chunked"} | UserHeaders],
Head = roadrunner_http1:response(Status, Headers, ~""),
_ = roadrunner_telemetry:response_send(
roadrunner_transport:send(Socket, Head), loop_response_head
),
Push = make_push(Socket),
info_loop(Socket, Handler, Push, State).
%% Selective receive on every Erlang message → handler:handle_info/3,
%% **except** OTP-internal shapes (`{system, _, _}` for the `sys`
%% protocol, `{'$gen_call', _, _}` and `{'$gen_cast', _}` for
%% gen_server/gen_statem requests). Those would only reach the conn
%% via misuse (the conn is a plain proc_lib loop, not a gen_*) and
%% delivering them to the user handler would surface a confusing
%% shape it has no reason to pattern-match on. Each OTP shape gets
%% its own no-op clause that re-enters the loop; non-OTP messages
%% fall through to the catch-all `Info` clause. On `{stop, _}` we
%% emit the size-0 chunked terminator and return.
%%
%% **No `after` clause:** the loop blocks indefinitely until the
%% handler returns `{stop, _}` from `handle_info/3`. A handler that
%% never receives a stop-triggering message keeps the connection
%% open forever; that's the contract for `{loop, ...}` responses
%% (e.g. SSE feeds).
-spec info_loop(roadrunner_transport:socket(), module(), roadrunner_handler:push_fun(), term()) ->
ok.
info_loop(Socket, Handler, Push, State) ->
receive
{system, _, _} ->
info_loop(Socket, Handler, Push, State);
{'$gen_call', _, _} ->
info_loop(Socket, Handler, Push, State);
{'$gen_cast', _} ->
info_loop(Socket, Handler, Push, State);
Info ->
case Handler:handle_info(Info, Push, State) of
{ok, NewState} ->
info_loop(Socket, Handler, Push, NewState);
{stop, _NewState} ->
_ = roadrunner_transport:send(Socket, ~"0\r\n\r\n"),
ok
end
end.
%% Push fun handed to the user handler. Same special-case as
%% `roadrunner_stream_response:stream_frame/2`: zero-length data
%% would encode as `0\r\n\r\n` — the chunked terminator — which
%% would end the response mid-loop. Skip empty pushes.
-spec make_push(roadrunner_transport:socket()) -> roadrunner_handler:push_fun().
make_push(Socket) ->
fun(Data) ->
case iolist_size(Data) of
0 ->
ok;
N ->
roadrunner_transport:send(Socket, [
integer_to_binary(N, 16),
~"\r\n",
Data,
~"\r\n"
])
end
end.