diff --git a/.gitignore b/.gitignore index dab51e8..ae0b8c8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ autobahn/server /benchmark/priv/file_1gb.bin /benchmark/priv/file_100kb.bin /benchmark/results -/benchmark/.wrk2 \ No newline at end of file +/benchmark/.wrk2 +NOTE.md \ No newline at end of file diff --git a/benchmark/bandit/lib/bandit_bench/router.ex b/benchmark/bandit/lib/bandit_bench/router.ex index c899a8f..dbf8f3a 100644 --- a/benchmark/bandit/lib/bandit_bench/router.ex +++ b/benchmark/bandit/lib/bandit_bench/router.ex @@ -1,6 +1,8 @@ defmodule BanditBench.Router do use Plug.Router + @sse_events 32 + plug(:match) plug(:dispatch) @@ -25,6 +27,14 @@ defmodule BanditBench.Router do conn end + get "/sse" do + conn + |> put_resp_header("content-type", "text/event-stream") + |> put_resp_header("cache-control", "no-cache") + |> send_chunked(200) + |> sse_burst() + end + get "/file/small" do conn |> put_resp_header("content-type", "application/octet-stream") @@ -41,6 +51,32 @@ defmodule BanditBench.Router do send_resp(conn, 404, "") end + defp sse_burst(conn) do + send(self(), {:sse_tick, 1}) + sse_loop(conn) + end + + defp sse_loop(conn) do + receive do + {:sse_tick, n} when n > @sse_events -> + conn + + {:sse_tick, n} -> + case chunk(conn, sse_event(n)) do + {:ok, conn} -> + send(self(), {:sse_tick, n + 1}) + sse_loop(conn) + + {:error, _reason} -> + conn + end + end + end + + defp sse_event(n) do + "event: tick\nid: #{n}\ndata: {\"n\":#{n},\"at\":\"benchmark\"}\n\n" + end + defp echo_chunked({:more, partial, conn}, acc) do Plug.Conn.read_body(conn, length: 4096) |> echo_chunked(acc <> partial) end diff --git a/benchmark/bench.sh b/benchmark/bench.sh index cdcad3a..efc7593 100755 --- a/benchmark/bench.sh +++ b/benchmark/bench.sh @@ -8,11 +8,12 @@ # Requires benchmark/.wrk2/wrk2, so run ./wrk2-setup.sh once first. # # Usage: -# ./bench.sh [--servers "ewe@5,mist"] [--pcts "50 75 90 95"] [--conns 50] -# [--duration 20s] [--warmup 5s] [--threads 4] +# ./bench.sh [--servers "ewe@5,mist"] [--endpoints "sse"] [--pcts "50 75 90 95"] +# [--conns 50] [--duration 20s] [--warmup 5s] [--threads 4] # [--probe-rate 500000] [--probe-duration 5s] # # --servers comma separated subset of server names (default: all) +# --endpoints comma separated subset of endpoint names (default: all) # --pcts space separated percentages of saturation to test (default: "50 75 90 95") # --conns connections held open per run (default: 50) # --duration wrk2 measured run duration (default: 20s) @@ -43,9 +44,12 @@ ENDPOINTS=( "echo|/echo|post_echo.lua" "echo_chunked|/echo/chunked|post_echo_chunked.lua" "stream|/stream|" + "sse|/sse|" "file_small|/file/small|" ) +SSE_EVENTS=32 + # server|endpoint|reason combos to skip before probing. Two distinct reasons: # - not_implemented: the server doesn't implement the feature the endpoint is # meant to exercise, so measuring it tells us nothing. @@ -56,6 +60,7 @@ SKIP=( "elli|echo_chunked|not_implemented" "httpd|echo_chunked|not_implemented" "elli|stream|unstable" + "elli|sse|unstable" "mist|stream|unstable" "ewe@4|stream|unstable" "ewe@4|file_small|unstable" @@ -81,6 +86,15 @@ server_selected() { esac } +endpoint_selected() { + local name="$1" + [ -z "${ONLY_ENDPOINTS:-}" ] && return 0 + case ",$ONLY_ENDPOINTS," in + *",$name,"*) return 0 ;; + *) return 1 ;; + esac +} + # Kills whatever is already listening on $1, e.g. a server orphaned by a # previous run that got interrupted before its own cleanup ran. free_port() { @@ -165,10 +179,12 @@ THREADS=4 PROBE_RATE=500000 PROBE_DURATION="5s" ONLY_SERVERS="" +ONLY_ENDPOINTS="" while [ $# -gt 0 ]; do case "$1" in --servers) ONLY_SERVERS="$2"; shift 2 ;; + --endpoints) ONLY_ENDPOINTS="$2"; shift 2 ;; --pcts) PCTS="$2"; shift 2 ;; --conns) CONNS="$2"; shift 2 ;; --duration) DURATION="$2"; shift 2 ;; @@ -308,6 +324,7 @@ for entry in "${SERVERS[@]}"; do for endpoint_entry in "${ENDPOINTS[@]}"; do IFS='|' read -r ename epath escript <<< "$endpoint_entry" + endpoint_selected "$ename" || continue if skip_endpoint "$name" "$ename"; then echo @@ -322,7 +339,11 @@ for entry in "${SERVERS[@]}"; do saturation=$(probe_saturation "$port" "$epath" "$escript") echo - echo " $ename (saturation ~$saturation req/s)" + if [ "$ename" = "sse" ]; then + echo " $ename (saturation ~$saturation streams/s = ~$(( saturation * SSE_EVENTS )) events/s)" + else + echo " $ename (saturation ~$saturation req/s)" + fi table_header for pct in $PCTS; do diff --git a/benchmark/elli/src/elli_bench_callback.erl b/benchmark/elli/src/elli_bench_callback.erl index 414583c..2905cc3 100644 --- a/benchmark/elli/src/elli_bench_callback.erl +++ b/benchmark/elli/src/elli_bench_callback.erl @@ -6,6 +6,8 @@ -include_lib("elli/include/elli.hrl"). +-define(SSE_EVENTS, 32). + %% elli never sets TCP_NODELAY on accepted sockets, so multi write responses %% (well chunked in particular) eat a Nagle and delayed-ACK stall between each %% write. @@ -26,6 +28,14 @@ handle('GET', [<<"stream">>], Req) -> spawn(fun() -> stream_hello(Ref) end), {chunk, []}; +%% elli has no SSE API, so the stream is plain chunked writes carrying the +%% event framing. +handle('GET', [<<"sse">>], Req) -> + Ref = elli_request:chunk_ref(Req), + spawn(fun() -> start_events(Ref) end), + {chunk, [{<<"Content-Type">>, <<"text/event-stream">>}, + {<<"Cache-Control">>, <<"no-cache">>}]}; + handle('GET', [<<"file">>, <<"small">>], _Req) -> {ok, [{<<"Content-Type">>, <<"application/octet-stream">>}], {file, "../priv/file_100kb.bin"}}; @@ -46,5 +56,28 @@ stream_hello(Ref) -> {error, _reason} -> ok end. +start_events(Ref) -> + self() ! {sse_tick, 1}, + stream_events(Ref). + +stream_events(Ref) -> + receive + {sse_tick, N} when N > ?SSE_EVENTS -> + elli_request:close_chunk(Ref); + {sse_tick, N} -> + case elli_request:send_chunk(Ref, sse_event(N)) of + ok -> + self() ! {sse_tick, N + 1}, + stream_events(Ref); + {error, _Reason} -> + ok + end + end. + +sse_event(N) -> + Id = integer_to_binary(N), + <<"event: tick\nid: ", Id/binary, + "\ndata: {\"n\":", Id/binary, ",\"at\":\"benchmark\"}\n\n">>. + handle_event(_Event, _Args, _Config) -> ok. diff --git a/benchmark/ewe@4/src/app.gleam b/benchmark/ewe@4/src/app.gleam index 66b7ef6..6b5a30b 100644 --- a/benchmark/ewe@4/src/app.gleam +++ b/benchmark/ewe@4/src/app.gleam @@ -5,6 +5,7 @@ import gleam/erlang/process.{type Subject} import gleam/http import gleam/http/request import gleam/http/response +import gleam/int import gleam/option import gleam/string import logging @@ -37,6 +38,7 @@ fn handle_request( } http.Post, "/echo/chunked" -> echo_chunked(request) http.Get, "/stream" -> stream_hello(request) + http.Get, "/sse" -> sse_burst(request) http.Get, "/file/small" -> { // head -c 100K /dev/urandom > file_100kb.bin let assert Ok(file) = @@ -134,3 +136,42 @@ fn stream_hello( on_close: fn(_conn, _state) { Nil }, ) } + +const sse_events = 32 + +type Tick { + Tick(Int) +} + +fn sse_burst( + request: request.Request(ewe.Connection), +) -> response.Response(ewe.ResponseBody) { + ewe.sse( + request, + on_init: fn(subject) { + process.send(subject, Tick(1)) + subject + }, + handler: fn(conn, subject, message) { + let Tick(n) = message + + case ewe.send_event(conn, tick_event(n)) { + Error(_reason) -> ewe.sse_stop_abnormal("failed to send event") + Ok(Nil) if n >= sse_events -> ewe.sse_stop() + Ok(Nil) -> { + process.send(subject, Tick(n + 1)) + ewe.sse_continue(subject) + } + } + }, + on_close: fn(_conn, _state) { Nil }, + ) +} + +fn tick_event(n: Int) -> ewe.SSEEvent { + let n = int.to_string(n) + + ewe.event("{\"n\":" <> n <> ",\"at\":\"benchmark\"}") + |> ewe.event_name("tick") + |> ewe.event_id(n) +} diff --git a/benchmark/ewe@5/src/app.gleam b/benchmark/ewe@5/src/app.gleam index a8205a1..ba05aff 100644 --- a/benchmark/ewe@5/src/app.gleam +++ b/benchmark/ewe@5/src/app.gleam @@ -4,6 +4,7 @@ import gleam/erlang/process import gleam/http import gleam/http/request import gleam/http/response +import gleam/int import gleam/option import logging @@ -42,6 +43,7 @@ fn handle_request( let writer = ewe.send_chunk(writer, <<"hello, ":utf8>>) ewe.finish_chunk(writer, <<"Joe!":utf8>>) } + http.Get, "/sse" -> sse_burst() http.Get, "/file/small" -> { // head -c 100K /dev/urandom > file_100kb.bin let assert Ok(file) = @@ -90,3 +92,40 @@ fn echo_chunked( Error(_error) -> response.new(400) |> response.set_body(ewe.Empty) } } + +const sse_events = 32 + +type Tick { + Tick(Int) +} + +fn sse_burst() -> response.Response(ewe.Body) { + ewe.sse( + response.new(200), + on_init: fn(subject) { + process.send(subject, Tick(1)) + subject + }, + handler: fn(conn, subject, message) { + let Tick(n) = message + + case ewe.send_event(conn, tick_event(n)) { + Error(_reason) -> ewe.sse_stop_abnormal("failed to send event") + Ok(Nil) if n >= sse_events -> ewe.sse_stop() + Ok(Nil) -> { + process.send(subject, Tick(n + 1)) + ewe.sse_continue(subject) + } + } + }, + on_close: fn(_conn, _state) { Nil }, + ) +} + +fn tick_event(n: Int) -> ewe.SseEvent { + let n = int.to_string(n) + + ewe.event("{\"n\":" <> n <> ",\"at\":\"benchmark\"}") + |> ewe.event_name("tick") + |> ewe.event_id(n) +} diff --git a/benchmark/httpd/src/httpd_bench_callback.erl b/benchmark/httpd/src/httpd_bench_callback.erl index ed9d6ec..8b0ae99 100644 --- a/benchmark/httpd/src/httpd_bench_callback.erl +++ b/benchmark/httpd/src/httpd_bench_callback.erl @@ -7,6 +7,8 @@ -define(FILE_CHUNK_SIZE, 262144). +-define(SSE_EVENTS, 32). + %% inets never sets TCP_NODELAY on accepted sockets and exposes no config for it, %% so every keep-alive response after the first eats a ~40ms Nagle and %% delayed-ACK stall. @@ -32,6 +34,11 @@ route("GET", "/stream", Info) -> send_stream(Info), {break, [{response, {already_sent, 200, 0}}]}; +%% httpd has no SSE API, so the stream is written straight to the socket. +route("GET", "/sse", Info) -> + send_sse(Info), + {break, [{response, {already_sent, 200, 0}}]}; + route("GET", "/file/small", Info) -> send_file(Info, "../priv/file_100kb.bin"), {break, [{response, {already_sent, 200, 0}}]}; @@ -60,6 +67,36 @@ send_stream(Info) -> send_chunk(SocketType, Socket, "Joe!"), httpd_socket:deliver(SocketType, Socket, "0\r\n\r\n"). +send_sse(Info) -> + #mod{socket_type = SocketType, socket = Socket} = Info, + Head = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/event-stream\r\n" + "Cache-Control: no-cache\r\n" + "Transfer-Encoding: chunked\r\n" + "Connection: keep-alive\r\n\r\n", + httpd_socket:deliver(SocketType, Socket, Head), + self() ! {sse_tick, 1}, + send_sse_events(SocketType, Socket), + httpd_socket:deliver(SocketType, Socket, "0\r\n\r\n"). + +send_sse_events(SocketType, Socket) -> + receive + {sse_tick, N} when N > ?SSE_EVENTS -> + ok; + {sse_tick, N} -> + send_chunk(SocketType, Socket, sse_event(N)), + self() ! {sse_tick, N + 1}, + send_sse_events(SocketType, Socket) + end. + +sse_event(N) -> + Id = integer_to_list(N), + lists:flatten([ + "event: tick\nid: ", Id, + "\ndata: {\"n\":", Id, ",\"at\":\"benchmark\"}\n\n" + ]). + send_chunk(SocketType, Socket, Data) -> Size = integer_to_list(length(Data), 16), httpd_socket:deliver(SocketType, Socket, [Size, "\r\n", Data, "\r\n"]). diff --git a/benchmark/mist/src/app.gleam b/benchmark/mist/src/app.gleam index 244ca61..fcb01e7 100644 --- a/benchmark/mist/src/app.gleam +++ b/benchmark/mist/src/app.gleam @@ -4,7 +4,10 @@ import gleam/erlang/process.{type Subject} import gleam/http import gleam/http/request import gleam/http/response +import gleam/int import gleam/option +import gleam/otp/actor +import gleam/string_tree import logging import mist @@ -37,14 +40,11 @@ fn handle_request( } http.Post, "/echo/chunked" -> echo_chunked(request) http.Get, "/stream" -> stream_hello(request) + http.Get, "/sse" -> sse_burst(request) http.Get, "/file/small" -> { // head -c 100K /dev/urandom > file_100kb.bin let assert Ok(file) = - mist.send_file( - "../priv/file_100kb.bin", - offset: 0, - limit: option.None, - ) + mist.send_file("../priv/file_100kb.bin", offset: 0, limit: option.None) response.Response( status: 200, @@ -55,11 +55,7 @@ fn handle_request( http.Get, "/file/big" -> { // head -c 1G /dev/urandom > file_1gb.bin let assert Ok(file) = - mist.send_file( - "../priv/file_1gb.bin", - offset: 0, - limit: option.None, - ) + mist.send_file("../priv/file_1gb.bin", offset: 0, limit: option.None) response.Response( status: 200, @@ -129,3 +125,47 @@ fn stream_hello( }, ) } + +/// Events emitted per `/sse` stream. Fixed across every benchmarked server so +/// that streams/sec times this is a comparable events/sec. +const sse_events = 32 + +type Tick { + Tick(Int) +} + +/// Emits `sse_events` events back to back with no pacing, then ends the +/// stream. A paced stream would measure the timer rather than the server. +fn sse_burst( + request: request.Request(mist.Connection), +) -> response.Response(mist.ResponseData) { + mist.server_sent_events( + request:, + initial_response: response.new(200), + init: fn(subject: Subject(Tick)) { + process.send(subject, Tick(1)) + subject + }, + loop: fn(subject, message, connection) { + let Tick(n) = message + + case mist.send_event(connection, tick_event(n)) { + Error(Nil) -> actor.stop() + Ok(Nil) if n >= sse_events -> actor.stop() + Ok(Nil) -> { + process.send(subject, Tick(n + 1)) + actor.continue(subject) + } + } + }, + ) +} + +fn tick_event(n: Int) -> mist.SSEEvent { + let n = int.to_string(n) + + string_tree.from_string("{\"n\":" <> n <> ",\"at\":\"benchmark\"}") + |> mist.event + |> mist.event_name("tick") + |> mist.event_id(n) +} diff --git a/benchmark/roadrunner/src/roadrunner_bench_callback.erl b/benchmark/roadrunner/src/roadrunner_bench_callback.erl index 3a582af..86c9c98 100644 --- a/benchmark/roadrunner/src/roadrunner_bench_callback.erl +++ b/benchmark/roadrunner/src/roadrunner_bench_callback.erl @@ -6,6 +6,8 @@ -include_lib("kernel/include/file.hrl"). +-define(SSE_EVENTS, 32). + handle(Req) -> route(roadrunner_req:method(Req), roadrunner_req:path(Req), Req). @@ -27,6 +29,14 @@ route(~"GET", ~"/stream", Req) -> end}, {Resp, Req}; +route(~"GET", ~"/sse", Req) -> + Headers = [ + {~"content-type", ~"text/event-stream"}, + {~"cache-control", ~"no-cache"} + ], + Resp = {stream, 200, Headers, fun(Send) -> start_sse(Send) end}, + {Resp, Req}; + route(~"GET", ~"/file/small", Req) -> send_file(Req, "../priv/file_100kb.bin"); route(~"GET", ~"/file/big", Req) -> @@ -35,6 +45,25 @@ route(~"GET", ~"/file/big", Req) -> route(_Method, _Path, Req) -> {roadrunner_resp:not_found(), Req}. +start_sse(Send) -> + self() ! {sse_tick, 1}, + send_sse(Send). + +send_sse(Send) -> + receive + {sse_tick, N} when N >= ?SSE_EVENTS -> + Send(sse_event(N), fin); + {sse_tick, N} -> + Send(sse_event(N), nofin), + self() ! {sse_tick, N + 1}, + send_sse(Send) + end. + +sse_event(N) -> + Id = integer_to_binary(N), + Data = <<"{\"n\":", Id/binary, ",\"at\":\"benchmark\"}">>, + iolist_to_binary(roadrunner_sse:event(~"tick", Data, Id)). + read_all_chunks(Req, Acc) -> case roadrunner_req:read_body_chunked(Req) of {more, Bytes, Req2} -> read_all_chunks(Req2, [Bytes | Acc]); diff --git a/benchmark/roadrunner/src/roadrunner_bench_sup.erl b/benchmark/roadrunner/src/roadrunner_bench_sup.erl index 927076f..6120a9b 100644 --- a/benchmark/roadrunner/src/roadrunner_bench_sup.erl +++ b/benchmark/roadrunner/src/roadrunner_bench_sup.erl @@ -19,6 +19,7 @@ init([]) -> {~"/echo", roadrunner_bench_callback, undefined}, {~"/echo/chunked", roadrunner_bench_callback, undefined}, {~"/stream", roadrunner_bench_callback, undefined}, + {~"/sse", roadrunner_bench_callback, undefined}, {~"/file/small", roadrunner_bench_callback, undefined}, {~"/file/big", roadrunner_bench_callback, undefined} ] diff --git a/src/ewe.gleam b/src/ewe.gleam index f476c91..bba8dd8 100644 --- a/src/ewe.gleam +++ b/src/ewe.gleam @@ -3,6 +3,8 @@ import ewe/internal/file import ewe/internal/handler as handler_ import ewe/internal/http1/body as http1_body import ewe/internal/http1/encoder +import ewe/internal/http1/sse as http1_sse +import ewe/internal/sse import gleam/bytes_tree import gleam/erlang/process import gleam/http @@ -33,6 +35,7 @@ pub type Body { Empty File(connection.File) Streaming(connection.Streaming) + Sse(connection.Sse) } pub type IpAddress { @@ -292,19 +295,24 @@ pub fn quiet(builder: Builder) -> Builder { Builder(..builder, on_start: fn(_scheme, _address) { Nil }) } -// Body and connection.Body are structurally identical. -@external(erlang, "ewe_ffi", "identity") -fn unsafe_to_internal_response( - response: response.Response(Body), -) -> response.Response(connection.Body) +fn to_internal_body(body: Body) -> connection.Body { + case body { + Bytes(tree) -> connection.Bytes(tree) + Text(text) -> connection.Text(text) + Empty -> connection.Empty + File(file) -> connection.File(file) + Streaming(streaming) -> connection.Streaming(streaming) + Sse(sse) -> connection.Sse(sse) + } +} /// Starts the server with the provided configuration. pub fn start( builder: Builder, ) -> Result(actor.Started(supervisor.Supervisor), actor.StartError) { let handler = fn(request) { - builder.handler(request) - |> unsafe_to_internal_response + let response = builder.handler(request) + response.set_body(response, to_internal_body(response.body)) } let pool = @@ -510,3 +518,107 @@ pub fn finish_response(writer: ResponseWriter) -> Nil { connection.Http2Writer -> todo as "HTTP/2 is not implemented yet!" } } + +/// A handle for writing to an open Server-Sent Events stream. +pub type SseConnection = + connection.SseConnection + +/// Server-Sent Events message. Build it with `event` or `comment`, then set +/// the remaining fields with `event_name`, `event_id` and `event_retry`. +pub type SseEvent = + sse.Event + +/// What an SSE stream does after the handler has dealt with the message. Build +/// it with `sse_continue`, `sse_stop` or `sse_stop_abnormal`. +pub opaque type SseNext(user_state) { + SseContinue(user_state) + SseStop + SseStopAbnormal(reason: String) +} + +/// Carries on with the stream, handling further messages with `user_state`. +pub fn sse_continue(user_state: user_state) -> SseNext(user_state) { + SseContinue(user_state) +} + +/// Ends the stream. +pub fn sse_stop() -> SseNext(user_state) { + SseStop +} + +/// Ends the stream, reporting `reason` as the cause. +pub fn sse_stop_abnormal(reason: String) -> SseNext(user_state) { + SseStopAbnormal(reason) +} + +/// Creates an event carrying `data`. Data spanning several lines is sent as +/// the repeated `data:` fields the client rejoins. +pub fn event(data: String) -> SseEvent { + sse.Event(..sse.new(), data: Some(data)) +} + +/// Creates a comment, which clients ignore. Sending one periodically is the +/// conventional way to stop an idle stream being closed by a proxy. +pub fn comment(text: String) -> SseEvent { + sse.Event(..sse.new(), comment: Some(text)) +} + +/// Sets the name of the event. +pub fn event_name(event: SseEvent, name: String) -> SseEvent { + sse.Event(..event, name: Some(name)) +} + +/// Sets the ID of the event. +pub fn event_id(event: SseEvent, id: String) -> SseEvent { + sse.Event(..event, id: Some(id)) +} + +/// Sets how long, in milliseconds, the client waits before reconnecting. +pub fn event_retry(event: SseEvent, retry: Int) -> SseEvent { + sse.Event(..event, retry: Some(retry)) +} + +/// Sends event to the client. +pub fn send_event( + conn: SseConnection, + event: SseEvent, +) -> Result(Nil, socket.SocketReason) { + case conn { + connection.Http1Sse(conn) -> http1_sse.send(conn, event) + connection.Http2Sse -> todo as "HTTP/2 is not implemented yet!" + } +} + +/// Turns the response into a Server-Sent Events stream, which runs until the +/// handler stops it or the client goes away. The HTTP/1.1 connection is +/// reusable afterwards as long as the handler ended the stream itself and the +/// client sent nothing during it. +/// +/// `on_init` is called once, with a subject the rest of your program uses to +/// push messages at the client, and returns the starting state. `handler` is +/// called for each message sent to that subject. `on_close` is called once +/// however the stream ended. +pub fn sse( + response: response.Response(a), + on_init on_init: fn(process.Subject(user_message)) -> user_state, + handler handler: fn(SseConnection, user_state, user_message) -> + SseNext(user_state), + on_close on_close: fn(SseConnection, user_state) -> Nil, +) -> response.Response(Body) { + let step = fn(conn, state, message) { + case handler(conn, state, message) { + SseContinue(state) -> sse.Proceed(state) + SseStop -> sse.Halt(connection.Stopped) + SseStopAbnormal(reason) -> sse.Halt(connection.StoppedAbnormal(reason)) + } + } + + let stream = fn(conn) { + case conn { + connection.Http1Sse(conn) -> http1_sse.run(conn, on_init, step, on_close) + connection.Http2Sse -> todo as "HTTP/2 is not implemented yet!" + } + } + + response.set_body(response, Sse(connection.SseMetadata(stream))) +} diff --git a/src/ewe/internal/http1.gleam b/src/ewe/internal/http1.gleam index eb12433..6fb29f6 100644 --- a/src/ewe/internal/http1.gleam +++ b/src/ewe/internal/http1.gleam @@ -103,8 +103,7 @@ pub fn handle_message( Error(error) -> { logging.log( logging.Error, - "Failed to parser.parse HTTP/1.x request: " - <> parser.error_to_string(error), + "Failed to parse HTTP/1.x request: " <> parser.error_to_string(error), ) Close @@ -184,13 +183,31 @@ fn send_response( } } } - encoder.RemainderSse(handler: sse_handler) -> { + encoder.RemainderSse(handler: sse_handler, framing:) -> { use Nil <- result.try(transport.send(transport, socket, head)) - connection.Http1Sse(http1.SseConnection(transport:, socket:)) - |> sse_handler - |> to_sse_sent - |> Ok + let outcome = + http1.SseConnection(transport:, socket:, self:, framing:) + |> connection.Http1Sse + |> sse_handler + + let _ = encoder.end_stream(transport, socket, framing) + + // The stream reports whether it left the socket at a point another + // request could start from. + let drained = drain_messages(self) + let stream_keep_alive = case drained.stream { + option.Some(http1.StreamFinished(keep_alive:)) -> keep_alive + option.None -> http1.CloseAfterResponse + } + + case outcome { + connection.StoppedAbnormal(reason) -> Ok(SentAbnormal(reason)) + connection.Stopped -> + http1.and_keep_alive(keep_alive, stream_keep_alive) + |> to_sent + |> Ok + } } } } @@ -202,13 +219,6 @@ fn to_sent(keep_alive: http1.KeepAlive) -> Sent { } } -fn to_sse_sent(outcome: connection.Outcome) -> Sent { - case outcome { - connection.Stopped -> SentClose - connection.StoppedAbnormal(reason:) -> SentAbnormal(reason) - } -} - const auto_drain_limit = 1_048_576 const auto_drain_chunk_bytes = 65_536 diff --git a/src/ewe/internal/http1/connection.gleam b/src/ewe/internal/http1/connection.gleam index 5dde5f3..7cec835 100644 --- a/src/ewe/internal/http1/connection.gleam +++ b/src/ewe/internal/http1/connection.gleam @@ -72,5 +72,10 @@ pub type StreamFraming { } pub type SseConnection { - SseConnection(transport: transport.Transport, socket: socket.Socket) + SseConnection( + transport: transport.Transport, + socket: socket.Socket, + self: process.Subject(Signal), + framing: StreamFraming, + ) } diff --git a/src/ewe/internal/http1/encoder.gleam b/src/ewe/internal/http1/encoder.gleam index bd79e4a..4d5d243 100644 --- a/src/ewe/internal/http1/encoder.gleam +++ b/src/ewe/internal/http1/encoder.gleam @@ -31,7 +31,10 @@ pub type Remainder { handler: fn(connection.ResponseWriter) -> Nil, framing: http1.StreamFraming, ) - RemainderSse(handler: fn(connection.SseConnection) -> connection.Outcome) + RemainderSse( + handler: fn(connection.SseConnection) -> connection.Outcome, + framing: http1.StreamFraming, + ) } pub type Encoded { @@ -48,7 +51,10 @@ pub fn encode_response( version: parser.Version, keep_alive: http1.KeepAlive, ) -> Result(Encoded, EncodeError) { - use state <- result.try(encode_headers(response.headers)) + use state <- result.try(encode_headers( + response.headers, + reserved(response.body), + )) let keep_alive = http1.and_keep_alive(keep_alive, state.keep_alive) let status = response.status @@ -75,7 +81,7 @@ pub fn encode_response( connection.Streaming(connection.StreamingMetadata(handler)) -> encode_stream(state, status, keep_alive, version, handler) connection.Sse(connection.SseMetadata(handler)) -> - close_delimited(state, status, RemainderSse(handler)) + encode_sse(state, status, keep_alive, version, handler) } // A HEAD response keeps the framing headers it would have had, minus the body. @@ -127,6 +133,43 @@ fn encode_stream( } } +/// On HTTP/1.1 the stream is framed as chunked, which proxies handle far better +/// than one delimited only by the close, and which leaves the socket sitting at +/// a known point afterwards. The connection is advertised as reusable on that +/// basis; whether it is handed back is settled once the stream ends. HTTP/1.0 +/// has no chunked encoding, so there the close is the framing and the +/// connection cannot survive it. +/// +/// The content type is fixed by the format and the no-cache is what keeps +/// intermediaries from buffering the stream, so both are written from constants +/// here rather than built into the handler's header list. +fn encode_sse( + state: EncodeState, + status: Int, + keep_alive: http1.KeepAlive, + version: parser.Version, + handler: fn(connection.SseConnection) -> connection.Outcome, +) -> Encoded { + case version { + parser.Http11 -> + Encoded( + build_head(state, status, keep_alive, << + "content-type: text/event-stream\r\ncache-control: no-cache\r\ntransfer-encoding: chunked\r\n":utf8, + >>), + keep_alive, + RemainderSse(handler:, framing: http1.ChunkedStream), + ) + parser.Http10 -> + Encoded( + build_head(state, status, http1.CloseAfterResponse, << + "content-type: text/event-stream\r\ncache-control: no-cache\r\n":utf8, + >>), + http1.CloseAfterResponse, + RemainderSse(handler:, framing: http1.CloseDelimitedStream), + ) + } +} + fn close_delimited( state: EncodeState, status: Int, @@ -157,19 +200,24 @@ pub type ResponseWriter = const last_chunk = <<"0\r\n\r\n":utf8>> -fn chunk_frame(chunk: BitArray) -> bytes_tree.BytesTree { - bytes_tree.new() - |> bytes_tree.append_string(int.to_base16(bit_array.byte_size(chunk))) - |> bytes_tree.append(<<"\r\n":utf8>>) - |> bytes_tree.append(chunk) - |> bytes_tree.append(<<"\r\n":utf8>>) +/// Wraps one piece of a streamed body in whatever delimits it on the wire. +pub fn frame( + chunk: bytes_tree.BytesTree, + framing: http1.StreamFraming, +) -> bytes_tree.BytesTree { + case framing { + http1.ChunkedStream -> + bytes_tree.new() + |> bytes_tree.append_string(int.to_base16(bytes_tree.byte_size(chunk))) + |> bytes_tree.append(<<"\r\n":utf8>>) + |> bytes_tree.append_tree(chunk) + |> bytes_tree.append(<<"\r\n":utf8>>) + http1.CloseDelimitedStream -> chunk + } } pub fn send_chunk(writer: ResponseWriter, chunk: BitArray) -> ResponseWriter { - let bytes = case writer.framing { - http1.ChunkedStream -> chunk_frame(chunk) - http1.CloseDelimitedStream -> bytes_tree.from_bit_array(chunk) - } + let bytes = frame(bytes_tree.from_bit_array(chunk), writer.framing) let _ = transport.send(writer.transport, writer.socket, bytes) writer } @@ -177,7 +225,11 @@ pub fn send_chunk(writer: ResponseWriter, chunk: BitArray) -> ResponseWriter { pub fn finish_chunk(writer: ResponseWriter, chunk: BitArray) -> Nil { // The terminator rides along with the last chunk to save a write. let bytes = case writer.framing { - http1.ChunkedStream -> bytes_tree.append(chunk_frame(chunk), last_chunk) + http1.ChunkedStream -> + bytes_tree.append( + frame(bytes_tree.from_bit_array(chunk), writer.framing), + last_chunk, + ) http1.CloseDelimitedStream -> bytes_tree.from_bit_array(chunk) } let _ = transport.send(writer.transport, writer.socket, bytes) @@ -207,13 +259,35 @@ fn finish(writer: ResponseWriter) -> Nil { |> process.send(writer.self, _) } +/// Which headers the encoder writes itself for a body, and so drops from the +/// handler's list rather than emitting twice. +type Reserved { + Framing + FramingAndSse +} + +fn reserved(body: connection.Body) -> Reserved { + case body { + connection.Sse(..) -> FramingAndSse + connection.Bytes(..) + | connection.Text(..) + | connection.Empty + | connection.File(..) + | connection.Streaming(..) -> Framing + } +} + fn encode_headers( headers: List(#(String, String)), + reserved: Reserved, ) -> Result(EncodeState, EncodeError) { let initial = EncodeState(bytes_tree.new(), http1.KeepAlive) use state, #(name, value) <- list.try_fold(headers, initial) + + // TODO: just trust the handler? case name { "content-length" | "transfer-encoding" | "date" -> Ok(state) + "content-type" | "cache-control" if reserved == FramingAndSse -> Ok(state) "connection" -> case parser.find_unsafe_header_byte(value) { Error(Nil) -> { @@ -226,7 +300,7 @@ fn encode_headers( } Ok(_position) -> Error(UnsafeHeader(name)) } - _name -> + _other -> case parser.find_unsafe_header_byte(name), parser.find_unsafe_header_byte(value) @@ -323,7 +397,7 @@ fn status_line(status: Int) -> BitArray { 502 -> <<"HTTP/1.1 502 Bad Gateway\r\n":utf8>> 503 -> <<"HTTP/1.1 503 Service Unavailable\r\n":utf8>> 504 -> <<"HTTP/1.1 504 Gateway Timeout\r\n":utf8>> - 505 -> <<"HTTP/1.1 505 HTTP parser.Version Not Supported\r\n":utf8>> + 505 -> <<"HTTP/1.1 505 HTTP Version Not Supported\r\n":utf8>> 506 -> <<"HTTP/1.1 506 Variant Also Negotiates\r\n":utf8>> 507 -> <<"HTTP/1.1 507 Insufficient Storage\r\n":utf8>> 508 -> <<"HTTP/1.1 508 Loop Detected\r\n":utf8>> diff --git a/src/ewe/internal/http1/sse.gleam b/src/ewe/internal/http1/sse.gleam new file mode 100644 index 0000000..5b9d964 --- /dev/null +++ b/src/ewe/internal/http1/sse.gleam @@ -0,0 +1,156 @@ +import ewe/internal/connection +import ewe/internal/http1/connection as http1 +import ewe/internal/http1/encoder +import ewe/internal/sse +import gleam/dynamic +import gleam/erlang/atom +import gleam/erlang/process +import glisten/socket +import glisten/socket/options +import glisten/transport + +/// Runs a Server-Sent Events stream, reporting through `conn.self` whether the +/// connection can carry another request afterwards. +pub fn run( + conn: http1.SseConnection, + on_init: fn(process.Subject(user_message)) -> user_state, + step: fn(connection.SseConnection, user_state, user_message) -> + sse.Step(user_state), + on_close: fn(connection.SseConnection, user_state) -> Nil, +) -> connection.Outcome { + let handle = connection.Http1Sse(conn) + let subject = process.new_subject() + let state = on_init(subject) + + case activate(conn) { + Ok(Nil) -> + loop(conn, handle, selector(subject), state, Clean, step, on_close) + Error(reason) -> { + on_close(handle, state) + finished(conn, http1.CloseAfterResponse) + connection.StoppedAbnormal(socket.reason_to_string(reason)) + } + } +} + +/// Whether anything happened during the stream that rules out handing the +/// connection back for another request. +type Reuse { + Clean + Spoiled +} + +fn loop( + conn: http1.SseConnection, + handle: connection.SseConnection, + selector: process.Selector(Received(user_message)), + state: user_state, + reuse: Reuse, + step: fn(connection.SseConnection, user_state, user_message) -> + sse.Step(user_state), + on_close: fn(connection.SseConnection, user_state) -> Nil, +) -> connection.Outcome { + case process.selector_receive_forever(selector) { + // Whatever the client sent has been taken off the socket and cannot be put + // back, so the connection is no longer safe to reuse. + ClientData -> loop(conn, handle, selector, state, Spoiled, step, on_close) + Disconnected -> { + on_close(handle, state) + finished(conn, http1.CloseAfterResponse) + connection.Stopped + } + Failed(reason) -> { + on_close(handle, state) + finished(conn, http1.CloseAfterResponse) + connection.StoppedAbnormal(reason) + } + Message(message) -> + case step(handle, state, message) { + sse.Proceed(state) -> + loop(conn, handle, selector, state, reuse, step, on_close) + sse.Halt(outcome) -> { + on_close(handle, state) + finished(conn, keep_alive(outcome, reuse)) + outcome + } + } + } +} + +/// Only a stream the handler ended itself, on a connection nothing else has +/// touched, leaves the socket sitting exactly at the end of the response. +fn keep_alive(outcome: connection.Outcome, reuse: Reuse) -> http1.KeepAlive { + case outcome, reuse { + connection.Stopped, Clean -> http1.KeepAlive + connection.Stopped, Spoiled -> http1.CloseAfterResponse + connection.StoppedAbnormal(..), _reuse -> http1.CloseAfterResponse + } +} + +fn finished(conn: http1.SseConnection, keep_alive: http1.KeepAlive) -> Nil { + http1.StreamFinished(keep_alive:) + |> http1.StreamSignal + |> process.send(conn.self, _) +} + +pub fn send( + conn: http1.SseConnection, + event: sse.Event, +) -> Result(Nil, socket.SocketReason) { + sse.encode(event) + |> encoder.frame(conn.framing) + |> transport.send(conn.transport, conn.socket, _) +} + +/// glisten re arms `{active, once}` only once its loop callback returns, and an +/// SSE stream does not return until it is over. Without switching to full +/// active mode the socket goes quiet for the whole stream, `tcp_closed` +/// included, and a client hanging up would never be noticed. +fn activate(conn: http1.SseConnection) -> Result(Nil, socket.SocketReason) { + transport.set_opts(conn.transport, conn.socket, [ + options.ActiveMode(options.Active), + ]) +} + +/// What woke the stream while it was waiting on the handler's subject. +type Received(user_message) { + Message(user_message) + Disconnected + Failed(reason: String) + ClientData +} + +/// glisten handles socket messages in its own loop, which is blocked for the +/// duration of the stream, so the stream has to match them itself. +fn selector( + subject: process.Subject(user_message), +) -> process.Selector(Received(user_message)) { + process.new_selector() + |> process.select_map(subject, Message) + |> process.select_record(atom.create("tcp_closed"), 1, disconnected) + |> process.select_record(atom.create("ssl_closed"), 1, disconnected) + |> process.select_record(atom.create("tcp_error"), 2, failed) + |> process.select_record(atom.create("ssl_error"), 2, failed) + |> process.select_record(atom.create("tcp"), 2, client_data) + |> process.select_record(atom.create("ssl"), 2, client_data) +} + +fn disconnected(_record: dynamic.Dynamic) -> Received(user_message) { + Disconnected +} + +fn failed(record: dynamic.Dynamic) -> Received(user_message) { + socket_error_reason(record) + |> socket.reason_to_string + |> Failed +} + +/// Clients are not expected to send anything once the stream is open. Matching +/// it anyway keeps a chatty one from growing the mailbox without bound, at the +/// cost of giving up on reusing the connection. +fn client_data(_record: dynamic.Dynamic) -> Received(user_message) { + ClientData +} + +@external(erlang, "http1_ffi", "socket_error_reason") +fn socket_error_reason(record: dynamic.Dynamic) -> socket.SocketReason diff --git a/src/ewe/internal/http1_ffi.erl b/src/ewe/internal/http1_ffi.erl index c4f7ea5..53fbe72 100644 --- a/src/ewe/internal/http1_ffi.erl +++ b/src/ewe/internal/http1_ffi.erl @@ -11,7 +11,8 @@ find_unsafe_header_byte/1, split_comma/1, list_to_bit_array/1, - bit_array_to_string/1 + bit_array_to_string/1, + socket_error_reason/1 ]). %% Compiles and caches match patterns once at module load. @@ -49,6 +50,10 @@ split_comma(Bin) -> list_to_bit_array(Bytes) -> erlang:list_to_binary(Bytes). +%% Reason carried by a `{tcp_error, Socket, Reason}` message. +socket_error_reason({_Tag, _Socket, Reason}) -> + Reason. + %% Validates UTF-8 via native BIFs and returns the bytes unchanged. bit_array_to_string(Bin) -> case unicode:bin_is_7bit(Bin) of diff --git a/src/ewe/internal/sse.gleam b/src/ewe/internal/sse.gleam new file mode 100644 index 0000000..ae34ed0 --- /dev/null +++ b/src/ewe/internal/sse.gleam @@ -0,0 +1,82 @@ +import ewe/internal/connection +import gleam/bytes_tree +import gleam/int +import gleam/list +import gleam/option + +pub type Event { + Event( + comment: option.Option(String), + name: option.Option(String), + id: option.Option(String), + retry: option.Option(Int), + data: option.Option(String), + ) +} + +/// What a protocol's stream loop does once the handler has seen a message. +pub type Step(user_state) { + Proceed(user_state) + Halt(connection.Outcome) +} + +pub fn new() -> Event { + Event( + comment: option.None, + name: option.None, + id: option.None, + retry: option.None, + data: option.None, + ) +} + +pub fn encode(event: Event) -> bytes_tree.BytesTree { + bytes_tree.new() + |> append_lines(": ", event.comment) + |> append_field("event: ", event.name) + |> append_field("id: ", event.id) + |> append_field("retry: ", option.map(event.retry, int.to_string)) + |> append_lines("data: ", event.data) + |> bytes_tree.append(newline) +} + +const newline = <<"\n":utf8>> + +/// A break in a single line field would be read as the start of the next field, +/// so it is dropped rather than allowed to forge one. +fn append_field( + tree: bytes_tree.BytesTree, + prefix: String, + value: option.Option(String), +) -> bytes_tree.BytesTree { + case value { + option.None -> tree + option.Some(value) -> + bytes_tree.append_string(tree, prefix) + |> bytes_tree.append_string(strip_breaks(value)) + |> bytes_tree.append(newline) + } +} + +/// A break splits the value over repeated fields, which the client rejoins. +fn append_lines( + tree: bytes_tree.BytesTree, + prefix: String, + value: option.Option(String), +) -> bytes_tree.BytesTree { + case value { + option.None -> tree + option.Some(value) -> { + use tree, line <- list.fold(split_breaks(value), tree) + bytes_tree.append_string(tree, prefix) + |> bytes_tree.append_string(line) + |> bytes_tree.append(newline) + } + } +} + +@external(erlang, "sse_ffi", "split_breaks") +fn split_breaks(value: String) -> List(String) + +@external(erlang, "sse_ffi", "strip_breaks") +fn strip_breaks(value: String) -> String diff --git a/src/ewe/internal/sse_ffi.erl b/src/ewe/internal/sse_ffi.erl new file mode 100644 index 0000000..752b720 --- /dev/null +++ b/src/ewe/internal/sse_ffi.erl @@ -0,0 +1,21 @@ +-module(sse_ffi). + +-on_load(init/0). +-export([ + init/0, + split_breaks/1, + strip_breaks/1 +]). + +init() -> + persistent_term:put( + {?MODULE, break}, + binary:compile_pattern([<<"\r\n">>, <<"\r">>, <<"\n">>]) + ), + ok. + +split_breaks(Bin) -> + binary:split(Bin, persistent_term:get({?MODULE, break}), [global]). + +strip_breaks(Bin) -> + binary:replace(Bin, persistent_term:get({?MODULE, break}), <<>>, [global]). diff --git a/test/ewe/internal/http1/encoder_test.gleam b/test/ewe/internal/http1/encoder_test.gleam new file mode 100644 index 0000000..379fbcc --- /dev/null +++ b/test/ewe/internal/http1/encoder_test.gleam @@ -0,0 +1,74 @@ +import ewe/internal/connection +import ewe/internal/http1/connection as http1 +import ewe/internal/http1/encoder +import ewe/internal/http1/parser +import gleam/bit_array +import gleam/bytes_tree +import gleam/http +import gleam/http/response +import gleam/list +import gleam/string + +fn head(headers: List(#(String, String)), body: connection.Body) -> String { + let response = response.Response(status: 200, headers:, body:) + let assert Ok(encoded) = + encoder.encode_response(response, http.Get, parser.Http11, http1.KeepAlive) + + let assert Ok(text) = + bytes_tree.to_bit_array(encoded.head) + |> bit_array.to_string + + text +} + +fn occurrences(haystack: String, needle: String) -> Int { + list.length(string.split(haystack, needle)) - 1 +} + +pub fn computed_content_length_is_the_only_one_test() { + let out = head([], connection.Text("hello")) + + assert occurrences(out, "content-length:") == 1 + assert string.contains(out, "content-length: 5") +} + +pub fn handler_content_length_is_dropped_test() { + let out = head([#("content-length", "999")], connection.Text("hello")) + + assert occurrences(out, "content-length:") == 1 + assert string.contains(out, "content-length: 5") +} + +pub fn uppercase_content_length_is_dropped_test() { + let out = head([#("Content-Length", "999")], connection.Text("hello")) + + assert occurrences(out, "999") == 0 + as "a differently cased content-length is still a content-length, and two on one response is a framing bug" +} + +pub fn mixed_case_transfer_encoding_is_dropped_test() { + let out = head([#("Transfer-Encoding", "chunked")], connection.Text("hi")) + + assert occurrences(out, "chunked") == 0 +} + +pub fn uppercase_connection_close_is_honoured_test() { + let out = head([#("Connection", "close")], connection.Text("hi")) + + assert string.contains(out, "connection: close") + assert occurrences(out, "connection:") == 1 + as "the handler's connection header must drive the real one, not sit beside it" +} + +pub fn header_names_are_normalised_to_lowercase_test() { + let out = head([#("X-Request-Id", "abc")], connection.Text("hi")) + + assert string.contains(out, "x-request-id: abc") + assert occurrences(out, "X-Request-Id") == 0 +} + +pub fn ordinary_headers_are_kept_test() { + let out = head([#("x-trace", "1")], connection.Text("hi")) + + assert string.contains(out, "x-trace: 1") +} diff --git a/test/ewe/internal/sse_test.gleam b/test/ewe/internal/sse_test.gleam new file mode 100644 index 0000000..275f566 --- /dev/null +++ b/test/ewe/internal/sse_test.gleam @@ -0,0 +1,78 @@ +import ewe/internal/sse +import gleam/bit_array +import gleam/bytes_tree +import gleam/option.{None, Some} + +fn encode(event: sse.Event) -> String { + let assert Ok(encoded) = + sse.encode(event) + |> bytes_tree.to_bit_array + |> bit_array.to_string + + encoded +} + +fn data(value: String) -> sse.Event { + sse.Event(..sse.new(), data: Some(value)) +} + +pub fn plain_data_test() { + assert encode(data("hello")) == "data: hello\n\n" +} + +pub fn empty_event_is_just_the_terminator_test() { + assert encode(sse.new()) == "\n" +} + +pub fn multiline_data_becomes_repeated_fields_test() { + assert encode(data("one\ntwo\nthree")) + == "data: one\ndata: two\ndata: three\n\n" +} + +pub fn crlf_counts_as_one_break_test() { + assert encode(data("one\r\ntwo")) == "data: one\ndata: two\n\n" + as "CRLF must match before the bare CR, or it would split twice" +} + +pub fn lone_cr_counts_as_a_break_test() { + assert encode(data("one\rtwo")) == "data: one\ndata: two\n\n" +} + +pub fn trailing_break_leaves_an_empty_field_test() { + assert encode(data("one\n")) == "data: one\ndata: \n\n" +} + +pub fn breaks_are_stripped_from_single_line_fields_test() { + let event = + sse.Event( + ..data("payload"), + name: Some("up\ndata: forged"), + id: Some("a\rb"), + ) + + assert encode(event) == "event: updata: forged\nid: ab\ndata: payload\n\n" + as "a break in a single-line field could otherwise forge another field" +} + +pub fn field_order_test() { + let event = + sse.Event( + comment: Some("why"), + name: Some("tick"), + id: Some("7"), + retry: Some(3000), + data: Some("body"), + ) + + assert encode(event) + == ": why\nevent: tick\nid: 7\nretry: 3000\ndata: body\n\n" +} + +pub fn comment_only_is_a_valid_keep_alive_test() { + assert encode(sse.Event(..sse.new(), comment: Some(""))) == ": \n\n" +} + +pub fn absent_fields_are_omitted_test() { + assert encode(sse.Event(..sse.new(), id: Some("9"), retry: None)) + == "id: 9\n\n" +}