diff --git a/.release-notes/81.md b/.release-notes/81.md new file mode 100644 index 0000000..55f1fbd --- /dev/null +++ b/.release-notes/81.md @@ -0,0 +1,52 @@ +## Provide the ability to send data in chunks to the client. + +NOTE: This _*is*_ a breaking change. + +The Handler API previous to this change only provided for one entry into the [Handler.finished](https://ponylang.github.io/http_server/http_server-Handler/#finished) behaviour. As pony Garbage Collection only occurs after behaviours exit and only one behaviour call is available, sending response data in blocks resulted in none of the blocks being able to be garbage collected until the entire response was sent to the client. + +The API for [Handler.finished](https://ponylang.github.io/http_server/http_server-Handler/#finished) has been modified to return a Bool. Returning "false" to this call results in the behaviour being called again. This gives Garbage Collection the opportunity to run, removing the potential for excessive memory allocation. + +The existing API provides the ability to receive body content in chunks. This change provides the same functionality for outgoing body content. + +### Required change for backward compatibility + +The example in the documentation for [Handler.finished](https://ponylang.github.io/http_server/http_server-Handler/#finished) is: + +```pony + fun ref finished(request_id: RequestID): Bool => + _session.send_raw( + Responses.builder() + .set_status(StatusOk) + .add_header("Content-Length", (_body.size() + _path.size() + 13).string()) + .add_header("Content-Type", "text/plain") + .finish_headers() + .add_chunk("received ") + .add_chunk((_body = ByteArrays).array()) + .add_chunk(" at ") + .add_chunk(_path) + .build(), + request_id + ) + _session.send_finished(request_id) +``` + +To retain the one-shot nature of this function, return `true`: + +```pony + fun ref finished(request_id: RequestID): Bool => + _session.send_raw( + Responses.builder() + .set_status(StatusOk) + .add_header("Content-Length", (_body.size() + _path.size() + 13).string()) + .add_header("Content-Type", "text/plain") + .finish_headers() + .add_chunk("received ") + .add_chunk((_body = ByteArrays).array()) + .add_chunk(" at ") + .add_chunk(_path) + .build(), + request_id + ) + _session.send_finished(request_id) + true +``` diff --git a/examples/chunked/chunked.pony b/examples/chunked/chunked.pony new file mode 100644 index 0000000..b1036d0 --- /dev/null +++ b/examples/chunked/chunked.pony @@ -0,0 +1,155 @@ +use "../../http_server" +use "net" +use "valbytes" +use "debug" + +actor Main + """ + A simple example of how to send your response body gradually. When sending + large responses you don't want the entire payload in memory at the same + time. + """ + new create(env: Env) => + for arg in env.args.values() do + if (arg == "-h") or (arg == "--help") then + _print_help(env) + return + end + end + + let port = try env.args(1)? else "50000" end + let limit = try env.args(2)?.usize()? else 100 end + let host = "localhost" + + // Start the top server control actor. + let server = Server( + TCPListenAuth(env.root), + LoggingServerNotify(env), // notify for server lifecycle events + BackendMaker.create(env) // factory for session-based application backend + where config = ServerConfig( // configuration of Server + where host' = host, + port' = port, + max_concurrent_connections' = limit) + ) + // everything is initialized, if all goes well + // the server is listening on the given port + // and thus kept alive by the runtime, as long its listening socket is not + // closed. + + fun _print_help(env: Env) => + env.err.print( + """ + Usage: + + chunked [ = 50000] [ = 100] + + """ + ) + + +class LoggingServerNotify is ServerNotify + """ + Notification class that is notified about + important lifecycle events for the Server + """ + let _env: Env + + new iso create(env: Env) => + _env = env + + fun ref listening(server: Server ref) => + """ + Called when the Server starts listening on its host:port pair via TCP. + """ + try + (let host, let service) = server.local_address().name()? + _env.err.print("connected: " + host + ":" + service) + else + _env.err.print("Couldn't get local address.") + _env.exitcode(1) + server.dispose() + end + + fun ref not_listening(server: Server ref) => + """ + Called when the Server was not able to start listening on its host:port pair via TCP. + """ + _env.err.print("Failed to listen.") + _env.exitcode(1) + + fun ref closed(server: Server ref) => + """ + Called when the Server is closed. + """ + _env.err.print("Shutdown.") + +class BackendMaker is HandlerFactory + """ + Fatory to instantiate a new HTTP-session-scoped backend instance. + """ + let _env: Env + + new val create(env: Env) => + _env = env + + fun apply(session: Session): Handler^ => + BackendHandler.create(_env, session) + +class BackendHandler is Handler + """ + Backend application instance for a single HTTP session. + + Executed on an actor representing the HTTP Session. + That means we have 1 actor per TCP Connection + (to be exact it is 2 as the TCPConnection is also an actor). + """ + let _env: Env + let _session: Session + + var _response: BuildableResponse + var stage: (ExHdrs | ExHello | ExWorld) = ExHdrs + + new ref create(env: Env, session: Session) => + _env = env + _session = session + _response = BuildableResponse(where status' = StatusOK) + + fun ref finished(request_id: RequestID): Bool => + """ + Start processing a request. + + Called when request-line and all headers have been parsed. + Body is not yet parsed, not even received maybe. + + In this example we have a simple State Machine which we + use to demonstrate how replies can be chunked in such a + way as we trade memory efficiency for speed. + + This tradeoff is needed when sending huge files. + + """ + + match stage + | ExHdrs => + var response: BuildableResponse iso = BuildableResponse(where status' = StatusOK) + response.add_header("Content-Type", "text/plain") + response.add_header("Server", "http_server.pony/0.2.1") + response.add_header("Content-Length", "12") + + _session.send_start(consume response, request_id) + stage = ExHello + return false + | ExHello => _session.send_chunk("Hello ", request_id) + stage = ExWorld + return false + | ExWorld => + _session.send_chunk("World!", request_id) + _session.send_finished(request_id) + stage = ExHdrs + return true + end + true // Never Reached + +primitive ExHdrs +primitive ExHello +primitive ExWorld diff --git a/examples/hello_world/main.pony b/examples/hello_world/main.pony index 4ca0a83..1438ed1 100644 --- a/examples/hello_world/main.pony +++ b/examples/hello_world/main.pony @@ -103,5 +103,3 @@ class BackendHandler is Handler _session.send_raw(_response, request_id) _session.send_finished(request_id) - fun ref finished(request_id: RequestID) => None - diff --git a/examples/httpserver/httpserver.pony b/examples/httpserver/httpserver.pony index 336ba8e..d2d6141 100644 --- a/examples/httpserver/httpserver.pony +++ b/examples/httpserver/httpserver.pony @@ -206,7 +206,7 @@ class BackendHandler is Handler ) end - fun ref finished(request_id: RequestID) => + fun ref finished(request_id: RequestID): Bool => """ Called when the last chunk has been handled and the full request has been received. @@ -229,4 +229,5 @@ class BackendHandler is Handler // Required call to finish request handling // if missed out, the server will misbehave _session.send_finished(request_id) + true diff --git a/http_server/_server_connection.pony b/http_server/_server_connection.pony index 01af136..3b7fecc 100644 --- a/http_server/_server_connection.pony +++ b/http_server/_server_connection.pony @@ -81,7 +81,9 @@ actor _ServerConnection is (Session & HTTP11RequestHandler) Indicates that the last *inbound* body chunk has been sent to `_chunk`. This is passed on to the back end. """ - _backend.finished(request_id) + if not _backend.finished(request_id) then + this._receive_finished(request_id) + end be _receive_failed(parse_error: RequestParseError, request_id: RequestID) => _backend.failed(parse_error, request_id) diff --git a/http_server/_test_pipelining.pony b/http_server/_test_pipelining.pony index 3736a21..eee04d7 100644 --- a/http_server/_test_pipelining.pony +++ b/http_server/_test_pipelining.pony @@ -26,7 +26,7 @@ class \nodoc\ val _PipeliningOrderHandlerFactory is HandlerFactory object ref is Handler let _session: Session = session - fun ref finished(request_id: RequestID) => + fun ref finished(request_id: RequestID): Bool => let rid = request_id.string() let res = Responses.builder() .set_status(StatusOK) @@ -48,6 +48,7 @@ class \nodoc\ val _PipeliningOrderHandlerFactory is HandlerFactory 0 ) ) + true end class \nodoc\ iso _PipeliningOrderTest is UnitTest diff --git a/http_server/handler.pony b/http_server/handler.pony index 4ee366f..42204f3 100644 --- a/http_server/handler.pony +++ b/http_server/handler.pony @@ -13,7 +13,7 @@ interface Handler ### Receiving Requests - When an [Request](http_server-Request.md) is received on an [Session](http_server-Session.md) actor, + When a [Request](http_server-Request.md) is received on a [Session](http_server-Session.md) actor, the corresponding [Handler.apply](http_server-Handler.md#apply) method is called with the request and a [RequestID](http_server-RequestID.md). The [Request](http_server-Request.md) contains the information extracted from HTTP Headers and the Request Line, but it does not @@ -37,7 +37,13 @@ interface Handler - exactly once: `apply(request_n, requestid_n)` - zero or more times: `chunk(data, requestid_n)` - - exactly once: `finished(requestid_n)` + - one or more times: `finished(requestid_n)` + + [Handler.finished](http_server-Handler.md#finished) is called one or more times depending to provide + the application the opportunity to send body data in chunks as opposed to one-shot. + + [Handler.finished](http_server-Handler.md#finished) should return false if there is more data to be sent, + or return true when there is no more data to be sent. And so on for `requestid_(n + 1)`. Only after `finished` has been called for a `RequestID`, the next request will be received by the Handler instance, there will @@ -81,7 +87,7 @@ interface Handler fun ref chunk(data: ByteSeq val, request_id: RequestID) => _body = _body + data - fun ref finished(request_id: RequestID) => + fun ref finished(request_id: RequestID): Bool => _session.send_raw( Responses.builder() .set_status(StatusOk) @@ -96,6 +102,7 @@ interface Handler request_id ) _session.send_finished(request_id) + true ``` """ @@ -113,11 +120,18 @@ interface Handler recent `Request` delivered by an `apply` notification. """ - fun ref finished(request_id: RequestID) => + fun ref finished(request_id: RequestID): Bool => """ - Notification that no more body chunks are coming. Delivery of this HTTP - message is complete. + The first call to this function indicates that no more body chunks + are coming. Delivery of this HTTP request's body is complete. + + Returning true indicates that the application has completed its + response for the provided request_id. + + Returning false indicates that there is more work to be done and + it should be called again. """ + true fun ref cancelled(request_id: RequestID) => """ diff --git a/http_server/sync_handler.pony b/http_server/sync_handler.pony index 977d2fc..9615f70 100644 --- a/http_server/sync_handler.pony +++ b/http_server/sync_handler.pony @@ -57,13 +57,14 @@ class SyncHandlerWrapper is Handler fun ref chunk(data: ByteSeq val, request_id: RequestID) => _body_buffer = _body_buffer + data - fun ref finished(request_id: RequestID) => + fun ref finished(request_id: RequestID): Bool => if not _sent then // resetting _body_buffer let res = _run_handler(_request, _body_buffer = ByteArrays) _session.send_raw(res, request_id) end _session.send_finished(request_id) + true