Skip to main content

src/roadrunner_http2_stream_response.erl

-module(roadrunner_http2_stream_response).
-moduledoc false.

%% HTTP/2 `{stream, ...}` response — body delivered as one or more
%% DATA frames, with optional trailers as a closing HEADERS frame.
%%
%% Mirrors `roadrunner_stream_response` so the handler-facing
%% `Send/2` API stays protocol-agnostic. Translation table:
%%
%% | `Send(Data, FinFlag)` | h1 wire | h2 wire |
%% |---|---|---|
%% | `Send(Data, nofin)` | one chunk | DATA, no END_STREAM |
%% | `Send(Data, fin)` | chunk + `0\\r\\n\\r\\n` | DATA + END_STREAM |
%% | `Send(Data, {fin, Trailers})` | chunk + `0` + trailers | DATA + HEADERS + END_STREAM |
%%
%% Empty data is special-cased to match the h1 behavior:
%% - `Send(<<>>, nofin)` is a no-op (no zero-length DATA frame).
%% - `Send(<<>>, fin)` emits an empty DATA frame with END_STREAM.
%% - `Send(<<>>, {fin, Trailers})` emits the trailer HEADERS frame
%%   (with END_STREAM) and no DATA.
%%
%% The `Send` callback runs in the worker process and synchronously
%% round-trips with the conn process for each emission — `Send`
%% returns only after the conn has written the corresponding frame
%% on the wire (or queued it pending a `WINDOW_UPDATE`). This
%% threads natural backpressure: a slow consumer stalls the worker
%% without us having to explicitly buffer.
%%
%% If the handler returns without calling `Send(_, fin)` /
%% `{fin, _}` we auto-close the stream with an empty `END_STREAM`
%% DATA frame so the peer doesn't see a half-open stream.

-export([run/5]).

%% Process-dict flag: set once Send observed a fin variant. Lives
%% in the WORKER's process dict (not the conn's), so isolation
%% across streams is automatic.
-define(FIN_KEY, '$roadrunner_http2_stream_fin').

-doc """
Send the response HEADERS (no `END_STREAM`) to the conn process,
invoke the user's stream fun with a `Send/2` callback, and ensure
the stream is closed by the time we return. Runs in the worker
process; every frame is synchronously round-tripped through the
conn (which owns HPACK encoder state and serialises wire writes).
""".
-spec run(
    pid(),
    pos_integer(),
    roadrunner_http:status(),
    roadrunner_http:headers(),
    roadrunner_handler:stream_fun()
) -> ok.
run(ConnPid, StreamId, Status, Headers, Fun) ->
    sync_send_headers(ConnPid, StreamId, Status, Headers, false),
    erase(?FIN_KEY),
    Send = fun(Data, FinFlag) -> do_send(ConnPid, StreamId, Data, FinFlag) end,
    _ = Fun(Send),
    erase(?FIN_KEY) =:= true orelse do_send(ConnPid, StreamId, <<>>, fin),
    ok.

do_send(ConnPid, StreamId, Data, nofin) ->
    %% Ship as iodata. The conn fast-paths single-frame sends
    %% without materialising and only flattens when chunking
    %% across window/MAX_FRAME_SIZE boundaries.
    iolist_size(Data) > 0 andalso sync_send_data(ConnPid, StreamId, Data, false),
    ok;
do_send(ConnPid, StreamId, Data, fin) ->
    sync_send_data(ConnPid, StreamId, Data, true),
    put(?FIN_KEY, true),
    ok;
do_send(ConnPid, StreamId, Data, {fin, Trailers}) ->
    iolist_size(Data) > 0 andalso sync_send_data(ConnPid, StreamId, Data, false),
    sync_send_trailers(ConnPid, StreamId, Trailers),
    put(?FIN_KEY, true),
    ok.

sync_send_headers(ConnPid, StreamId, Status, Headers, EndStream) ->
    sync(fun(Ref) ->
        _ = (ConnPid ! {h2_send_headers, self(), Ref, StreamId, Status, Headers, EndStream}),
        ok
    end).

sync_send_data(ConnPid, StreamId, Data, EndStream) ->
    sync(fun(Ref) ->
        _ = (ConnPid ! {h2_send_data, self(), Ref, StreamId, Data, EndStream}),
        ok
    end).

sync_send_trailers(ConnPid, StreamId, Trailers) ->
    sync(fun(Ref) ->
        _ = (ConnPid ! {h2_send_trailers, self(), Ref, StreamId, Trailers}),
        ok
    end).

sync(SendFun) ->
    Ref = make_ref(),
    ok = SendFun(Ref),
    receive
        {h2_send_ack, Ref} -> ok;
        {h2_stream_reset, _StreamId} -> exit(stream_reset)
    end.