diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md new file mode 100644 index 0000000000..b284bf2f19 --- /dev/null +++ b/docs/guides/streaming.md @@ -0,0 +1,190 @@ +A response body whose size is not known in advance, or which is simply too large +to fit in memory, can be produced on demand and sent using +[chunked transfer encoding](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Transfer-Encoding). + +Both provider forms below drive the same asynchronous engine: chunks are +written with asynchronous socket writes, so a slow client never blocks a +worker thread. Crow sets `Transfer-Encoding: chunked`, omits `Content-Length`, +and requests one chunk at a time; the next chunk is not requested until the +previous one has been written (socket backpressure). + +## Synchronous provider + +Use `#!cpp set_chunked_content_provider(, )` when the next +piece of the body is available on demand: + +```cpp +bool provider(std::string& chunk); +``` + +Fill `chunk` with the next piece and return `#!cpp true` while more data is +coming, `#!cpp false` on the last invocation. The provider runs on the +connection's executor, shared with the peer-disconnect watch, the stream +timers, and every other connection on that worker: return promptly. When the +next chunk may not be ready at call time, use the asynchronous provider below; +a synchronous provider that blocks stalls all of that for the duration of the +block. An empty chunk sends nothing, but an empty chunk returned with more +data promised immediately schedules the next provider call, so polling with +empty chunks busy-loops the worker: wait in the asynchronous provider +instead, or finish the transfer. + +```cpp +CROW_ROUTE(app, "/numbers") +([](const crow::request&, crow::response& res) { + int remaining = 100; + res.set_chunked_content_provider( + [remaining](std::string& chunk) mutable -> bool { + if (remaining == 0) + return false; + chunk = std::to_string(100 - remaining) + '\n'; + --remaining; + return true; + }, + "text/plain"); + res.end(); +}); +``` + +A provider that discovers midway that the body cannot be finished should not +let the response end normally: without `Content-Length`, the terminating frame +is the only sign that the body is complete. Return +`#!cpp crow::chunk_result` instead of `#!cpp bool` for this case +(`more` / `done` / `abort`): on `abort` Crow closes the connection without the +terminating frame, so the client sees a truncated body. + +## Asynchronous provider + +Use `#!cpp set_async_chunked_content_provider` when producing a chunk may take +arbitrary time (server-sent events, MJPEG, a message queue). The provider +receives a completion callback for one requested chunk: + +```cpp +void provider(crow::response::async_chunk_completion_t complete); +``` + +The provider must return promptly and call `complete` exactly once per +invocation, inline or later from any thread; Crow publishes the result onto +the connection's executor. Pass `more` while data remains, `done` for the +final chunk (a nonempty chunk is written before the terminating frame), or +`abort` to truncate. `complete` returns `#!cpp false` for a repeated or +inactive result and when publication fails; a source can use that to stop +itself after the client is gone: + +```cpp +if (!complete(crow::chunk_result::more, std::move(data))) { + source->cancel(); + return; +} +``` + +A complete route: an event stream that forwards messages from a queue and +truncates the body when the source fails. + +```cpp +auto queue = std::make_shared(); + +CROW_ROUTE(app, "/events") +([queue](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [queue](crow::response::async_chunk_completion_t complete) { + queue->async_pop([complete = std::move(complete)](Event event) { + if (event.failed) + { + complete(crow::chunk_result::abort, ""); + return; + } + complete(event.last ? crow::chunk_result::done : crow::chunk_result::more, + std::move(event.payload)); + }); + }, + "text/event-stream"); + res.set_chunked_completion_handler([queue](bool) { queue->close(); }); + res.end(); +}); +``` + +## Completion handler + +`#!cpp set_chunked_completion_handler(void (bool clean))` runs exactly once +after writing stops, on every path: normal completion, abort, provider +exception, write error or timeout, peer disconnect, server shutdown, and, as +the last resort, connection destruction. `clean` is `#!cpp true` when the +provider finished normally and every write succeeded, and also when the +response was written without invoking the provider at all (HEAD, a bodyless +status, or a body source configured after the provider): the handler reports +the outcome of whatever response write replaced the stream. Release the data +source here. + +Move-assigning another response over one that carries a completion handler +follows the setter: a source that carries a handler installs it in place of +the existing one, and a source that carries none leaves the existing one in +place. An error response built by a route or by Crow's exception handler +therefore still reports through the handler that the abandoned stream +installed. + +## Connection lifecycle + +- **Keep-alive.** After a cleanly finished stream the connection serves the + next sequential request as usual. An application-supplied + `Connection: close` header commits the server: the full body and terminator + are written first, then the connection closes. +- **Pipelining.** While a stream is in flight, arriving bytes are never + served: the stream finishes correctly and the connection then closes. The + same applies to bytes that follow a deferred response's request in the same + read. A request that arrives in a later packet while an ordinary deferred + response is pending is served after it, as a normal sequential request. + Clients should not pipeline behind a streamed response; sequential + keep-alive clients are unaffected. +- **Peer disconnect.** Crow keeps a read armed while streaming, so a client + that goes away aborts the transfer even while the provider is idle. +- **Deferred end().** Installing a provider and calling `#!cpp res.end()` + later, from any thread, is supported; finalization always runs on the + connection's executor. +- **HEAD** responds with `Transfer-Encoding: chunked`, no `Content-Length`, + and no body; the provider is not invoked and the completion handler reports + `clean == true`. **204/205/304** suppress the provider with correct bodyless + framing (a `205` carries `Content-Length: 0`), and a handler-returned **1xx** + is normalized to a `500` server error (an interim status cannot be a final + response). **HTTP/1.0** clients receive a finite `505`: Crow refuses to + downgrade a chunked stream to close-delimited output. +- **Framing.** A response has exactly one body source: among the configured + sources (providers, static file), the one configured last wins, while a + plain `body` assignment or `#!cpp write()` does not replace an installed + provider. The wire carries exactly one `Transfer-Encoding: chunked`, no + `Content-Length`, and no `Trailer`, regardless of headers set around the + provider. + +## Time and size bounds + +- Every socket write of a stream runs under the connection timeout + (`#!cpp app.timeout(...)`): a client that stops reading trips it and the + transfer is aborted unclean. +- The wait for a provider is unlimited by default: an idle provider is normal + for long-lived streams. `#!cpp app.stream_idle_timeout(seconds)` (0 by + default, at most 255 seconds) aborts a stream whose provider stays silent + longer than the limit. It cannot interrupt a synchronous provider that + blocks the connection's executor. +- `#!cpp app.max_stream_chunk_size(bytes)` (16 MiB by default, 0 disables the + cap) limits a single chunk; a larger chunk aborts the transfer. Together + with the write deadline it bounds each write: a chunk must be written + within `app.timeout(...)` seconds, so very large chunks over slow links + need a larger timeout or smaller chunks. + +## Server shutdown + +Stopping the server aborts active streams: the provider is released, the +completion handler reports `clean == false` exactly once, and a late +`#!cpp end()` from an application thread is a safe no-op release. Providers +and handlers should not throw; a provider exception that escapes before a +result is reported is logged and treated as an abort, and an exception that +escapes the completion handler is logged and swallowed (the transfer has +already finished). + +A response belongs to the connection once `#!cpp end()` has been called: a +second `#!cpp end()` is a latched no-op, and calling any setter or +`#!cpp write()` after that point is not supported. Build the response, +providers included, inside the route handler: after the handler returns with +the response deferred, the connection (and worker shutdown) may access it +concurrently, and the only operations safe to call from any thread are +`#!cpp end()`, `#!cpp end(body)`, `#!cpp is_completed()`, and +`#!cpp is_alive()`. diff --git a/include/crow/app.h b/include/crow/app.h index 7aa60522db..8692d74721 100644 --- a/include/crow/app.h +++ b/include/crow/app.h @@ -507,7 +507,10 @@ namespace crow /// \brief Set the response body size (in bytes) beyond which Crow automatically streams responses (Default is 1MiB) /// - /// Any streamed response is unaffected by Crow's timer, and therefore won't timeout before a response is fully sent. + /// This threshold applies to string and static-file bodies with a known + /// length; a body streamed this way is unaffected by Crow's timer. Chunk + /// providers are configured separately, and each of their socket writes + /// runs under timeout(). self_t& stream_threshold(size_t threshold) { res_stream_threshold_ = threshold; @@ -520,6 +523,39 @@ namespace crow return res_stream_threshold_; } + /// \brief Limit how long a chunk provider may stay idle between chunks (in seconds, at most 255; 0 disables the limit) + /// + /// Unlimited by default: an idle provider is normal for long-lived streams + /// such as server-sent events. When set, a stream whose provider produces + /// no result within the limit is aborted and reported unclean. + self_t& stream_idle_timeout(uint8_t seconds) + { + stream_idle_timeout_ = seconds; + return *this; + } + + /// \brief Get the chunk provider idle limit (in seconds; 0 = unlimited) + uint8_t stream_idle_timeout() + { + return stream_idle_timeout_; + } + + /// \brief Cap the size (in bytes) of a single chunk supplied by a chunk provider (default 16 MiB; 0 disables the cap) + /// + /// A chunk above the cap aborts the stream: the connection closes without + /// the terminating frame and completion is reported unclean. + self_t& max_stream_chunk_size(size_t bytes) + { + max_stream_chunk_size_ = bytes; + return *this; + } + + /// \brief Get the cap on a single provider-supplied chunk (in bytes) + size_t max_stream_chunk_size() + { + return max_stream_chunk_size_; + } + self_t& register_blueprint(Blueprint& blueprint) { @@ -920,6 +956,8 @@ namespace crow detail::socket::tcp_socket_options tcp_socket_options_{}; detail::socket::tcp_socket_options websocket_tcp_socket_options_{}; size_t res_stream_threshold_ = 1048576; + uint8_t stream_idle_timeout_ = 0; + size_t max_stream_chunk_size_ = 16 * 1048576; Router router_; bool static_routes_added_{false}; diff --git a/include/crow/common.h b/include/crow/common.h index a8e58f4abc..4be9d9c2ae 100644 --- a/include/crow/common.h +++ b/include/crow/common.h @@ -214,6 +214,7 @@ namespace crow BAD_GATEWAY = 502, SERVICE_UNAVAILABLE = 503, GATEWAY_TIMEOUT = 504, + HTTP_VERSION_NOT_SUPPORTED = 505, VARIANT_ALSO_NEGOTIATES = 506, WEBDAV_INSUFFICIENT_STORAGE = 507 }; diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index 2ebf72a956..ad46b4df62 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -12,7 +12,13 @@ #include #include #include +#include +#include +#include #include +#include +#include +#include #include #include "crow/http_parser_merged.h" @@ -42,33 +48,87 @@ namespace crow static std::atomic connectionCount; #endif + namespace detail { +#ifdef CROW_ENABLE_ASYNC_CHUNK_PUBLICATION_TEST_HOOK + void invoke_async_chunk_publication_test_hook(); +#endif + + class connection_lifecycle_registry { + public: + template + bool track(const std::shared_ptr& connection) + { + std::lock_guard lock(mutex_); + if (shutting_down_) + { + return false; + } + connections_[connection.get()] = [connection] { + connection->shutdown_on_worker_exit(); + }; + return true; + } + + void untrack(const void* connection) noexcept + { + std::lock_guard lock(mutex_); + connections_.erase(connection); + } + + // Not noexcept: copying the tracked callbacks can allocate, and a rare + // failure must reach the worker future instead of std::terminate. + void shutdown_all() + { + std::vector> shutdown_callbacks; + { + std::lock_guard lock(mutex_); + if (shutting_down_) + { + return; + } + shutting_down_ = true; + shutdown_callbacks.reserve(connections_.size()); + for (const auto& item : connections_) + { + shutdown_callbacks.push_back(item.second); + } + } + for (auto& shutdown : shutdown_callbacks) + { + shutdown(); + } + } + + private: + std::mutex mutex_; + bool shutting_down_{false}; + std::unordered_map> connections_; + }; + } // namespace detail + /// An HTTP connection. template class Connection : public std::enable_shared_from_this> { friend struct crow::response; + friend class detail::connection_lifecycle_registry; +#ifdef CROW_ENABLE_ASYNC_CHUNK_PUBLICATION_TEST_HOOK + friend struct connection_test_access; +#endif public: - Connection( - asio::io_context& io_context, - Handler* handler, - const std::string& server_name, - std::tuple* middlewares, - std::function& get_cached_date_str_f, - detail::task_timer& task_timer, - typename Adaptor::context* adaptor_ctx_, - std::atomic& queue_length): - adaptor_(io_context, adaptor_ctx_), - handler_(handler), - parser_(this), - req_(parser_.req), - server_name_(server_name), - middlewares_(middlewares), - get_cached_date_str(get_cached_date_str_f), - task_timer_(task_timer), - res_stream_threshold_(handler->stream_threshold()), - queue_length_(queue_length) + Connection(asio::io_context& io_context, + Handler* handler, + const std::string& server_name, + std::tuple* middlewares, + std::function& get_cached_date_str_f, + detail::task_timer& task_timer, + typename Adaptor::context* adaptor_ctx_, + std::atomic& queue_length, + std::shared_ptr lifecycle_registry = nullptr): + adaptor_(io_context, adaptor_ctx_), handler_(handler), parser_(this), req_(parser_.req), server_name_(server_name), middlewares_(middlewares), get_cached_date_str(get_cached_date_str_f), task_timer_(task_timer), res_stream_threshold_(handler->stream_threshold()), stream_idle_timeout_(handler->stream_idle_timeout()), max_stream_chunk_size_(handler->max_stream_chunk_size()), queue_length_(queue_length), lifecycle_registry_(lifecycle_registry) { + res.deferred_lifecycle_ = std::make_shared(); queue_length_++; #ifdef CROW_ENABLE_DEBUG connectionCount++; @@ -76,8 +136,8 @@ namespace crow #endif } - ~Connection() - { + ~Connection() { + destroy_async_chunk_transfer(); queue_length_--; #ifdef CROW_ENABLE_DEBUG connectionCount--; @@ -195,8 +255,11 @@ namespace crow { res.complete_request_handler_ = nullptr; auto self = this->shared_from_this(); - res.is_alive_helper_ = [self]() -> bool { - return self->adaptor_.is_open(); + // The helper must stay callable from foreign threads while the + // executor closes the socket, so it reads shared atomic state + // instead of the socket. + res.is_alive_helper_ = [flag = peer_open_]() -> bool { + return flag->load(); }; detail::middleware_call_helpercomplete_request(); + asio::dispatch(self->adaptor_.get_io_context(), [self] { self->complete_request(); }); }; need_to_call_after_handlers_ = true; handler_->handle(req_, res, routing_handle_result_); - if (add_keep_alive_) - res.set_header("connection", "Keep-Alive"); + if (need_to_call_after_handlers_) + parser_.stop_after_message(); + // After the handler returns the response may be deferred and + // owned by another thread; the header writer emits the + // keep-alive field from add_keep_alive_ instead. } else { @@ -226,8 +297,20 @@ namespace crow /// Call the after handle middleware and send the write the response to the connection. void complete_request() { + // The IOCP scheduler can run handlers queued before io_context::stop(); + // a stopped worker must not finalize: the connection stays tracked and + // worker shutdown reports the response instead. + if (adaptor_.get_io_context().stopped()) + { + return; + } + untrack_connection_lifecycle(); CROW_LOG_INFO << "Response: " << this << ' ' << req_.raw_url << ' ' << res.code << ' ' << close_connection_; - res.is_alive_helper_ = nullptr; + { + // Serialized with foreign-thread is_alive() readers. + std::lock_guard lifecycle_lock(res.deferred_lifecycle_->mutex); + res.is_alive_helper_ = nullptr; + } if (need_to_call_after_handlers_) { @@ -241,7 +324,12 @@ namespace crow decltype(*middlewares_)>({}, *middlewares_, ctx_, req_, res); } #ifdef CROW_ENABLE_COMPRESSION - if (!res.body.empty() && handler_->compression_used()) + // A chunked response discards res.body, so compressing it would + // only stamp a stale Content-Encoding onto uncompressed chunks. + // Interim and bodyless statuses discard res.body below for the + // same reason. + if (!res.is_chunked_type() && !res.body.empty() && handler_->compression_used() && + res.code >= 200 && response_status_allows_body()) { std::string accept_encoding = req_.get_header_value("Accept-Encoding"); if (!accept_encoding.empty() && res.compressed) @@ -269,23 +357,205 @@ namespace crow } #endif + if (res.code < 200) + { + // An interim (1xx) status cannot be a final response: the + // client would keep waiting for the real one. The dedicated + // 100-continue and upgrade paths write interim responses + // directly; here the status is a handler mistake. + res.code = 500; + res.body.clear(); + res.file_info = response::static_file_info{}; + res.async_chunk_provider_ = nullptr; + res.body_source_ = response::body_source_kind::none; + res.headers.erase("Content-Length"); + res.headers.erase("Transfer-Encoding"); + res.headers.erase("Content-Encoding"); + res.headers.erase("Trailer"); + res.manual_length_header = false; + } + + if (res.completed_ && res.skip_body) + { + // HEAD representation metadata is captured after middleware and + // compression, so it matches what the equivalent GET would send. + if (res.is_chunked_type()) + { + // HEAD keeps "Transfer-Encoding: chunked" and omits "Content-Length". + res.async_chunk_provider_ = nullptr; + res.body = ""; + res.manual_length_header = true; + } + else + { + if (!res.is_static_type() && !res.body.empty()) + { + res.set_header("Content-Length", std::to_string(res.body.size())); + res.manual_length_header = true; + } + res.body = ""; + if (res.is_static_type()) + { + res.manual_length_header = true; + } + } + } + + // An application-supplied "Connection: close" commits the server + // to closing after this response (RFC 9112 §9.6), and a closing + // connection must not advertise Keep-Alive. + if (!close_connection_ && response_connection_header_requests_close()) + { + close_connection_ = true; + } + if (close_connection_) + { + add_keep_alive_ = false; + if (!req_.close_connection && !res.headers.count("connection")) + { + // A server-initiated close is made explicit when the + // application did not; a client that itself asked for + // close needs no echo. + res.set_header("Connection", "close"); + } + } + + // Bodyless statuses neutralize the provider first: a bodyless + // response needs no chunked coding, so HTTP/1.0 clients can + // receive it instead of the chunked-coding rejection. + if (!response_status_allows_body()) + { + suppress_response_body_for_status(); + } + else if (req_.http_ver_major == 1 && req_.http_ver_minor == 0 && res.is_chunked_type()) + { + reject_http_1_0_chunked_response(); + } + if (res.is_chunked_type()) + { + // Framing headers may have been touched after the provider was + // installed; a chunked body carries exactly one Transfer-Encoding + // and no Content-Length. Crow never sends trailer fields, so an + // application "Trailer" header must not survive either. + res.headers.erase("Content-Length"); + res.headers.erase("Transfer-Encoding"); + res.headers.erase("Trailer"); + res.set_header("Transfer-Encoding", "chunked"); + res.manual_length_header = true; + } prepare_buffers(); + if (res.skip_body) + { + // Header preparation may synthesize an error representation. HEAD reports + // its length while omitting its bytes from the response message. + res.body.clear(); + } - if (res.is_static_type()) + const bool write_static = res.is_static_type() && !res.skip_body; + const bool write_chunked = res.is_chunked_type() && !res.skip_body; + + // A synchronous write can immediately parse a pipelined request. Reset the + // completed request's flags before entering any write path so reentrant + // routing sees independent response state. + res.manual_length_header = false; + res.skip_body = false; + + if (write_static) { do_write_static(); } + else if (write_chunked) + { + do_write_async_chunked(); + } else { do_write_general(); } + } private: + bool response_status_allows_body() const noexcept + { + // Callers running before the 1xx normalization in + // complete_request() must exclude interim statuses themselves. + return res.code != status::NO_CONTENT && res.code != status::RESET_CONTENT && + res.code != status::NOT_MODIFIED; + } + + bool response_connection_header_requests_close() const + { + const auto connection_headers = res.headers.equal_range("connection"); + for (auto field = connection_headers.first; field != connection_headers.second; ++field) + { + const std::string& value = field->second; + std::size_t token_start = 0; + while (token_start <= value.size()) + { + std::size_t token_end = value.find(',', token_start); + if (token_end == std::string::npos) + token_end = value.size(); + const std::size_t begin = value.find_first_not_of(" \t", token_start); + if (begin != std::string::npos && begin < token_end) + { + const std::size_t end = value.find_last_not_of(" \t", token_end - 1) + 1; + if (utility::string_equals(std::string_view(value.data() + begin, end - begin), "close")) + return true; + } + token_start = token_end + 1; + } + } + return false; + } + + void suppress_response_body_for_status() + { + res.body.clear(); + res.file_info = response::static_file_info{}; + res.async_chunk_provider_ = nullptr; + res.body_source_ = response::body_source_kind::none; + res.manual_length_header = true; + // Crow never sends a trailer section, so the header must not + // survive on a bodyless response either. Content-Encoding stays: + // 204 and 304 may carry representation metadata (RFC 9110 + // §15.3.5, §15.4.5). + res.headers.erase("Trailer"); + + if (res.code == status::NO_CONTENT) + { + res.headers.erase("Content-Length"); + res.headers.erase("Transfer-Encoding"); + } + else if (res.code == status::RESET_CONTENT) + { + // 205 forbids content but is framed as an empty representation: + // without an explicit zero length a keep-alive client would + // wait for close-delimited content. The forced empty + // representation carries no coding, so a leftover + // Content-Encoding would misdescribe it. + res.headers.erase("Transfer-Encoding"); + res.headers.erase("Content-Length"); + res.headers.erase("Content-Encoding"); + res.set_header("Content-Length", "0"); + } + else + { + // 304 keeps representation metadata such as Content-Length; + // hop-by-hop transfer framing must not survive on a bodyless + // response. + res.headers.erase("Transfer-Encoding"); + } + } + void prepare_buffers() { - res.complete_request_handler_ = nullptr; - res.is_alive_helper_ = nullptr; + { + // Serialized with foreign-thread is_alive() readers. + std::lock_guard lifecycle_lock(res.deferred_lifecycle_->mutex); + res.complete_request_handler_ = nullptr; + res.is_alive_helper_ = nullptr; + } if (!adaptor_.is_open()) { @@ -296,69 +566,811 @@ namespace crow res.write_header_into_buffer(buffers_, content_length_, add_keep_alive_, server_name_); } + void reject_http_1_0_chunked_response() + { + res.async_chunk_provider_ = nullptr; + res.body_source_ = response::body_source_kind::none; + res.body.clear(); + res.code = status::HTTP_VERSION_NOT_SUPPORTED; + res.manual_length_header = false; + res.headers.erase("Content-Length"); + res.headers.erase("Transfer-Encoding"); + res.headers.erase("Content-Encoding"); + res.headers.erase("Trailer"); + res.set_header("Connection", "close"); + add_keep_alive_ = false; + close_connection_ = true; + res.notify_chunked_completion(false); + } + void do_write_static() { - asio::write(adaptor_.socket(), buffers_); + auto completion_handler = std::move(res.chunk_complete_); + res.chunk_complete_ = nullptr; + const std::string file_path = res.file_info.path; + const auto expected_file_size = static_cast(res.file_info.statbuf.st_size); + error_code ec; + asio::write(adaptor_.socket(), buffers_, ec); + bool write_failed = static_cast(ec); + if (ec) + { + CROW_LOG_ERROR << ec << " - buffer write error happened while sending static response headers."; + } - if (res.file_info.statResult == 0) + if (!write_failed && res.file_info.statResult == 0) { - std::ifstream is(res.file_info.path.c_str(), std::ios::in | std::ios::binary); + std::ifstream is(file_path.c_str(), std::ios::in | std::ios::binary); std::vector buffers{1}; char buf[16384]; - is.read(buf, sizeof(buf)); - while (is.gcount() > 0) + std::uintmax_t bytes_read = 0; + if (!is) + { + write_failed = true; + CROW_LOG_ERROR << "Unable to open static response file " << file_path << "."; + } + while (!write_failed && bytes_read < expected_file_size) { + const auto remaining = expected_file_size - bytes_read; + const auto read_size = static_cast(std::min(sizeof(buf), remaining)); + is.read(buf, read_size); + const auto count = is.gcount(); + if (count <= 0) + { + write_failed = true; + CROW_LOG_ERROR << "Static response file " << file_path + << " ended before its advertised Content-Length."; + break; + } buffers[0] = asio::buffer(buf, is.gcount()); - error_code ec = do_write_sync(buffers); - if (ec) { + ec = do_write_sync(buffers); + if (ec) + { + write_failed = true; CROW_LOG_ERROR << ec << " - buffer write error happened while sending content of file " - << res.file_info.path << ". Writing stopped premature."; + << file_path << ". Writing stopped premature."; break; } - is.read(buf, sizeof(buf)); + bytes_read += static_cast(count); + } + if (!write_failed && (is.bad() || bytes_read != expected_file_size)) + { + write_failed = true; + CROW_LOG_ERROR << "Unable to read the complete static response file " << file_path << "."; } } - if (close_connection_) + if (close_connection_ || write_failed) { adaptor_.shutdown_readwrite(); - adaptor_.close(); + close_adaptor(); CROW_LOG_DEBUG << this << " from write (static)"; } + response::invoke_chunked_completion(std::move(completion_handler), !write_failed); + + res.end(); + res.clear(); + buffers_.clear(); + parser_.clear(); + + if (!close_connection_ && !write_failed && need_to_start_read_after_complete_) + { + resume_input_after_response(); + } + else if (write_failed) + { + need_to_start_read_after_complete_ = false; + } + } + + /// Format a chunk size the way chunked transfer encoding wants it: lowercase hex. + static std::string chunk_size_to_hex(std::size_t value) + { + static const char digits[] = "0123456789abcdef"; + if (value == 0) + { + return "0"; + } + std::string out; + while (value != 0) { + out.insert(out.begin(), digits[value & 0xF]); + value >>= 4; + } + return out; + } + + enum class AsyncChunkPhase { + writing_headers, + requesting_chunk, + writing_chunk, + writing_terminator, + completed, + aborted + }; + + struct AsyncChunkTransfer; + + struct AsyncChunkRequest { + std::mutex mutex; + bool completed{false}; + bool active{true}; + asio::io_context* io_context{nullptr}; + std::weak_ptr connection; + std::weak_ptr transfer; + }; + + template + static void post_async_chunk_completion(asio::io_context& io_context, CompletionHandler&& handler) { +#ifdef CROW_ENABLE_ASYNC_CHUNK_PUBLICATION_TEST_HOOK + detail::invoke_async_chunk_publication_test_hook(); +#endif + asio::post(io_context, std::forward(handler)); + } + + static void log_async_chunk_publication_failure(const char* message, + const std::exception_ptr& failure) noexcept { + if (!failure) { + return; + } + + try { + try { + std::rethrow_exception(failure); + } catch (const std::exception& e) { + CROW_LOG_ERROR << message << ": " << e.what(); + } catch (...) { + CROW_LOG_ERROR << message << "."; + } + } catch (...) { + // Publication errors must not escape through logging on provider threads. + } + } + + static void log_duplicate_async_chunk_completion() noexcept { + try { + CROW_LOG_WARNING << "An asynchronous chunk completion callback was invoked more than once."; + } catch (...) { + // Logging must not throw through the provider thread. + } + } + + struct AsyncChunkTransfer { + ~AsyncChunkTransfer() { + notify_completion(false); + } + + void notify_completion(bool clean) noexcept { + response::chunk_complete_t handler; + { + // Once-only: completion may run from the worker or from destruction. + std::lock_guard lock(completion_mutex); + if (completion_reported) { + return; + } + completion_reported = true; + handler = std::move(completion_handler); + } + + if (!handler) { + return; + } + + try { + handler(clean); + } catch (const std::exception& e) { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler: " << e.what(); + } catch (...) { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler."; + } + } + + AsyncChunkPhase phase{AsyncChunkPhase::writing_headers}; + response::async_chunk_provider_t provider; + response::chunk_complete_t completion_handler; + std::string header_bytes; + std::string chunk; + std::string chunk_header; + std::vector buffers; + std::shared_ptr pending_request; + detail::task_timer::identifier_type idle_task_id{}; + bool idle_task_armed{false}; + std::mutex completion_mutex; + bool completion_reported{false}; + }; + + void do_write_async_chunked() { + // Every socket write of the transfer runs under the connection + // deadline; only the provider wait is unbounded by default. + start_deadline(); + + auto state = std::make_shared(); + state->provider = std::move(res.async_chunk_provider_); + res.async_chunk_provider_ = nullptr; + state->completion_handler = std::move(res.chunk_complete_); + res.chunk_complete_ = nullptr; + async_chunk_transfer_ = state; + parser_.stop_after_message(); + + auto self = this->shared_from_this(); + if (!track_connection_lifecycle()) + { + shutdown_on_worker_exit(); + return; + } + // The header buffers point into res.headers; the transfer owns a copy + // so nothing aliases user-reachable state while the write is in flight. + try + { + std::size_t header_size = 0; + for (const auto& buffer : buffers_) + { + header_size += buffer.size(); + } + state->header_bytes.reserve(header_size); + for (const auto& buffer : buffers_) + { + state->header_bytes.append(static_cast(buffer.data()), buffer.size()); + } + buffers_.clear(); + asio::async_write( + adaptor_.socket(), asio::buffer(state->header_bytes), [self, state](const error_code& ec, std::size_t /*bytes_transferred*/) { + if (self->async_chunk_transfer_ != state) + { + return; + } + + if (ec) + { + CROW_LOG_ERROR << ec + << " - buffer write error happened while sending response start / headers. " + "Writing stopped prematurely."; + self->finish_async_chunked(state, false, true); + return; + } + + state->header_bytes.clear(); + self->cancel_deadline_timer(); + self->request_async_chunk(state); + }); + } + catch (...) + { + // A failed header copy or write start must not strand the + // tracked transfer. + CROW_LOG_ERROR << "Failed to send the response header. Closing connection."; + buffers_.clear(); + finish_async_chunked(state, false, true); + return; + } + } + + void arm_provider_idle_timeout(const std::shared_ptr& state) + { + if (stream_idle_timeout_ == 0 || async_chunk_transfer_ != state) + { + return; + } + + auto self = this->shared_from_this(); + std::weak_ptr weak_state = state; + state->idle_task_id = task_timer_.schedule( + [self, weak_state] { + auto active_state = weak_state.lock(); + if (!active_state) + { + return; + } + + active_state->idle_task_armed = false; + if (self->async_chunk_transfer_ == active_state && active_state->phase == AsyncChunkPhase::requesting_chunk) + { + CROW_LOG_WARNING << "Chunk provider exceeded the stream idle limit; aborting the transfer."; + self->finish_async_chunked(active_state, false, true); + } + }, + stream_idle_timeout_); + state->idle_task_armed = true; + } + + void cancel_provider_idle_timeout(const std::shared_ptr& state) + { + if (!state->idle_task_armed) + { + return; + } + + const auto task_id = state->idle_task_id; + state->idle_task_armed = false; + task_timer_.cancel(task_id); + } + + static void invalidate_async_chunk_request(const std::shared_ptr& state) { + auto request = std::move(state->pending_request); + if (!request) { + return; + } + + std::lock_guard lock(request->mutex); + request->active = false; + request->io_context = nullptr; + request->connection.reset(); + request->transfer.reset(); + } + + void shutdown_on_worker_exit() noexcept { + shutting_down_ = true; + auto state = std::move(async_chunk_transfer_); + if (state) { + // The worker has stopped dispatching handlers. Keep buffers referenced by + // outstanding writes intact until Asio releases their handlers. + invalidate_async_chunk_request(state); + cancel_provider_idle_timeout(state); + state->phase = AsyncChunkPhase::aborted; + state->provider = nullptr; + } + + adaptor_.shutdown_readwrite(); + close_adaptor(); + + if (state) { + untrack_connection_lifecycle(); + state->notify_completion(false); + } + else + { + response::chunk_complete_t completion_handler; + bool release_registry_entry = false; + { + std::lock_guard lifecycle_lock(res.deferred_lifecycle_->mutex); + completion_handler = std::move(res.chunk_complete_); + res.chunk_complete_ = nullptr; + // Release the application's providers; the response is + // otherwise left alone for a late end() to observe. + res.async_chunk_provider_ = nullptr; + res.body_source_ = response::body_source_kind::none; + res.is_alive_helper_ = nullptr; + if (res.completed_) + { + // end() already latched, so no later call can release + // the registry entry; release it here instead. + res.complete_request_handler_ = nullptr; + release_registry_entry = true; + } + else + { + std::weak_ptr weak_self = this->shared_from_this(); + try + { + res.complete_request_handler_ = [weak_self] { + if (auto self = weak_self.lock()) + { + self->release_stopped_deferred_response(); + } + }; + } + catch (...) + { + // On allocation failure the latch still protects a + // late end(); the entry is released below. + res.complete_request_handler_ = nullptr; + release_registry_entry = true; + } + } + } + if (release_registry_entry) + { + untrack_connection_lifecycle(); + } + response::invoke_chunked_completion(std::move(completion_handler), false); + } + } + + void release_stopped_deferred_response() noexcept + { + { + std::lock_guard lifecycle_lock(res.deferred_lifecycle_->mutex); + res.complete_request_handler_ = nullptr; + } + untrack_connection_lifecycle(); + } + + void destroy_async_chunk_transfer() noexcept { + shutting_down_ = true; + auto state = std::move(async_chunk_transfer_); + if (!state) { + // Destruction is the fallback report for a deferred response + // that never started writing. + res.notify_chunked_completion(false); + return; + } + + untrack_connection_lifecycle(); + + // Invalidate before destruction so racing completion cannot post to a dead executor. + invalidate_async_chunk_request(state); + state->phase = AsyncChunkPhase::aborted; + state->provider = nullptr; + state->buffers.clear(); + + adaptor_.shutdown_readwrite(); + close_adaptor(); + state->notify_completion(false); + } + + bool track_connection_lifecycle() + { + auto lifecycle_registry = lifecycle_registry_.lock(); + if (!lifecycle_registry || lifecycle_tracked_) + { + return true; + } + if (!lifecycle_registry->track(this->shared_from_this())) + { + return false; + } + lifecycle_tracked_ = true; + return true; + } + + void untrack_connection_lifecycle() noexcept + { + auto lifecycle_registry = lifecycle_registry_.lock(); + if (lifecycle_registry && lifecycle_tracked_) + { + lifecycle_registry->untrack(this); + lifecycle_tracked_ = false; + } + } + + void request_async_chunk(const std::shared_ptr& state) { + if (async_chunk_transfer_ != state) { + return; + } + if (adaptor_.get_io_context().stopped()) + { + finish_async_chunked(state, false, true); + return; + } + try + { + request_async_chunk_impl(state); + } + catch (...) + { + // A failed request setup must not strand the tracked transfer: + // on the first chunk no peer watch is armed yet and the write + // deadline was just cancelled, so a throw here would leave the + // client waiting until worker shutdown. + CROW_LOG_ERROR << "Failed to request the next chunk. Closing connection."; + finish_async_chunked(state, false, true); + } + } + + void request_async_chunk_impl(const std::shared_ptr& state) + { + state->phase = AsyncChunkPhase::requesting_chunk; + auto request = std::make_shared(); + request->io_context = &adaptor_.get_io_context(); + request->connection = this->shared_from_this(); + request->transfer = state; + state->pending_request = request; + arm_provider_idle_timeout(state); + + response::async_chunk_completion_t complete = [request](response::chunk_result result, + std::string chunk) mutable -> bool { + std::unique_lock lock(request->mutex); + if (request->completed) + { + lock.unlock(); + log_duplicate_async_chunk_completion(); + return false; + } + + if (!request->active) + { + return false; + } + + auto self = request->connection.lock(); + if (!self) + { + return false; + } + + const auto publish = [request](response::chunk_result published_result, std::string published_chunk) { + // Posting is unconditional, including when the provider completed inline. + post_async_chunk_completion(*request->io_context, + [weak_self = request->connection, + weak_state = request->transfer, + published_result, + published_chunk = std::move(published_chunk)]() mutable { + auto active_connection = weak_self.lock(); + auto active_state = weak_state.lock(); + if (!active_connection || !active_state) + { + return; + } + active_connection->handle_async_chunk_result( + active_state, published_result, std::move(published_chunk)); + }); + }; + + std::exception_ptr publication_failure; + try + { + publish(result, std::move(chunk)); + request->completed = true; + return true; + } + catch (...) + { + publication_failure = std::current_exception(); + } + + std::exception_ptr abort_publication_failure; + try + { + publish(response::chunk_result::abort, ""); + request->completed = true; + } + catch (...) + { + abort_publication_failure = std::current_exception(); + } + + lock.unlock(); + log_async_chunk_publication_failure( + "Failed to publish an asynchronous chunk completion; abort recovery was attempted", + publication_failure); + log_async_chunk_publication_failure( + "Failed to publish asynchronous chunk abort recovery; the transfer remains active for " + "shutdown cleanup", + abort_publication_failure); + return false; + }; + + try { + state->provider(complete); + } catch (const std::exception& e) { + CROW_LOG_ERROR << "An uncaught exception occurred in the asynchronous chunk provider: " << e.what(); + complete(response::chunk_result::abort, ""); + } catch (...) { + CROW_LOG_ERROR << "An uncaught exception occurred in the asynchronous chunk provider."; + complete(response::chunk_result::abort, ""); + } + + if (async_chunk_transfer_ == state && state->phase == AsyncChunkPhase::requesting_chunk) + { + do_read(); + } + } + + void handle_async_chunk_result(const std::shared_ptr& state, + response::chunk_result result, + std::string chunk) { + if (async_chunk_transfer_ != state || state->phase != AsyncChunkPhase::requesting_chunk) { + return; + } + if (adaptor_.get_io_context().stopped()) + { + finish_async_chunked(state, false, true); + return; + } + + invalidate_async_chunk_request(state); + cancel_provider_idle_timeout(state); + + // The peer watch read stays outstanding: one read and one write may + // be in flight concurrently on the connection executor. + apply_async_chunk_result(state, result, std::move(chunk)); + } + + void apply_async_chunk_result(const std::shared_ptr& state, + response::chunk_result result, + std::string chunk) + { + if (async_chunk_transfer_ != state || state->phase != AsyncChunkPhase::requesting_chunk) + { + return; + } + + if (result == response::chunk_result::abort) { + finish_async_chunked(state, false, true); + return; + } + + if (chunk.empty()) { + if (result == response::chunk_result::done) { + write_async_chunk_terminator(state); + } else { + request_async_chunk(state); + } + return; + } + + if (max_stream_chunk_size_ > 0 && chunk.size() > max_stream_chunk_size_) + { + CROW_LOG_ERROR << "Chunk provider supplied " << chunk.size() + << " bytes, above the configured cap; aborting the transfer."; + finish_async_chunked(state, false, true); + return; + } + + state->phase = AsyncChunkPhase::writing_chunk; + auto self = this->shared_from_this(); + try + { + state->chunk = std::move(chunk); + state->chunk_header = chunk_size_to_hex(state->chunk.size()); + state->chunk_header += crlf; + state->buffers.clear(); + state->buffers.reserve(3); + state->buffers.emplace_back(state->chunk_header.data(), state->chunk_header.size()); + state->buffers.emplace_back(state->chunk.data(), state->chunk.size()); + state->buffers.emplace_back(crlf.data(), crlf.size()); + + start_deadline(); + asio::async_write( + adaptor_.socket(), + state->buffers, + [self, state, result](const error_code& ec, std::size_t /*bytes_transferred*/) { + if (self->async_chunk_transfer_ != state) + { + return; + } + + if (ec) + { + CROW_LOG_ERROR + << ec << " - buffer write error happened while sending a chunk. Writing stopped prematurely."; + self->finish_async_chunked(state, false, true); + return; + } + + self->cancel_deadline_timer(); + state->buffers.clear(); + state->chunk.clear(); + state->chunk_header.clear(); + if (result == response::chunk_result::done) + { + self->write_async_chunk_terminator(state); + } + else + { + self->request_async_chunk(state); + } + }); + } + catch (...) + { + // A failed frame build or write start must not strand the + // tracked transfer. + CROW_LOG_ERROR << "Failed to send a chunk frame. Closing connection."; + finish_async_chunked(state, false, true); + } + } + + void write_async_chunk_terminator(const std::shared_ptr& state) { + if (async_chunk_transfer_ != state) { + return; + } + + static constexpr char terminator[] = "0\r\n\r\n"; + state->phase = AsyncChunkPhase::writing_terminator; + auto self = this->shared_from_this(); + try + { + state->buffers.clear(); + state->buffers.emplace_back(terminator, sizeof(terminator) - 1); + + start_deadline(); + asio::async_write(adaptor_.socket(), + state->buffers, + [self, state](const error_code& ec, std::size_t /*bytes_transferred*/) { + if (self->async_chunk_transfer_ != state) + { + return; + } + + if (ec) + { + CROW_LOG_ERROR << ec + << " - buffer write error happened while sending the last chunk."; + self->finish_async_chunked(state, false, true); + return; + } + + self->cancel_deadline_timer(); + self->finish_async_chunked(state, true, false); + }); + } + catch (...) + { + // A failed terminator write start must not strand the tracked + // transfer. + CROW_LOG_ERROR << "Failed to send the terminating chunk. Closing connection."; + finish_async_chunked(state, false, true); + } + } + + void finish_async_chunked(const std::shared_ptr& state, bool clean, bool force_close) { + if (async_chunk_transfer_ != state) { + return; + } + cancel_deadline_timer(); + + const bool resume_input = clean && !force_close && !close_connection_ && need_to_start_read_after_complete_; + state->phase = clean ? AsyncChunkPhase::completed : AsyncChunkPhase::aborted; + invalidate_async_chunk_request(state); + cancel_provider_idle_timeout(state); + state->provider = nullptr; + state->buffers.clear(); + async_chunk_transfer_.reset(); + untrack_connection_lifecycle(); + + if (force_close) { + adaptor_.shutdown_readwrite(); + close_adaptor(); + CROW_LOG_DEBUG << this << " from write (async chunked, aborted or write error)"; + } + + state->notify_completion(clean); + + if (close_connection_ && !force_close) { + adaptor_.shutdown_readwrite(); + close_adaptor(); + CROW_LOG_DEBUG << this << " from write (async chunked)"; + } + res.end(); res.clear(); buffers_.clear(); parser_.clear(); + + if (resume_input) + { + resume_input_after_response(); + } + else + { + need_to_start_read_after_complete_ = false; + } + } + + void resume_input_after_response() + { + need_to_start_read_after_complete_ = false; + start_deadline(); + do_read(); } void do_write_general() { error_code ec; + bool write_failed = false; + auto completion_handler = std::move(res.chunk_complete_); + res.chunk_complete_ = nullptr; if (res.body.length() < res_stream_threshold_) { res_body_copy_.swap(res.body); buffers_.emplace_back(res_body_copy_.data(), res_body_copy_.size()); ec = do_write_sync(buffers_); - if (ec) { + write_failed = static_cast(ec); + if (write_failed) { CROW_LOG_ERROR << ec << " - buffer write error happened while sending response. Writing stopped premature."; } - if (need_to_start_read_after_complete_) - { - need_to_start_read_after_complete_ = false; - start_deadline(); - do_read(); - } } else { - asio::write(adaptor_.socket(), buffers_,ec); // Write the response start / headers - if (ec) { - CROW_LOG_ERROR << ec << "- buffer write error happened while sending response start / headers. Writing stopped premature."; + asio::write(adaptor_.socket(), buffers_, ec); // Write the response start / headers + write_failed = static_cast(ec); + if (write_failed) { + CROW_LOG_ERROR << ec + << " - buffer write error happened while sending response start / headers. Writing " + "stopped premature."; } cancel_deadline_timer(); - if (res.body.length() > 0) - { + if (!write_failed && !res.body.empty()) { std::vector buffers{1}; const uint8_t* data = reinterpret_cast(res.body.data()); size_t length = res.body.length(); @@ -366,71 +1378,159 @@ namespace crow { size_t to_transfer = CROW_MIN(16384UL, length - transferred); buffers[0] = asio::const_buffer(data + transferred, to_transfer); - ec = do_write_sync(buffers); + asio::write(adaptor_.socket(), buffers, ec); if (ec) { + write_failed = true; CROW_LOG_ERROR << ec << " - " << transferred << " - buffer write error happened while sending response. Writing stopped premature."; break; } transferred += to_transfer; } } - if (close_connection_) - { - adaptor_.shutdown_readwrite(); - adaptor_.close(); - CROW_LOG_DEBUG << this << " from write (res_stream)"; - } res.end(); res.clear(); buffers_.clear(); parser_.clear(); } + + if (completion_handler) + { + try + { + completion_handler(!write_failed); + } + catch (const std::exception& e) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler: " << e.what(); + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler."; + } + } + + if (close_connection_ || write_failed) { + adaptor_.shutdown_readwrite(); + close_adaptor(); + need_to_start_read_after_complete_ = false; + CROW_LOG_DEBUG << this << " from write (res" << (write_failed ? ", write error" : "") << ")"; + } else if (need_to_start_read_after_complete_) { + resume_input_after_response(); + } } void do_read() { + if (read_in_progress_ || shutting_down_ || !adaptor_.is_open()) + { + return; + } + read_in_progress_ = true; auto self = this->shared_from_this(); adaptor_.socket().async_read_some( asio::buffer(buffer_), [self](const error_code& ec, std::size_t bytes_transferred) { - bool error_while_reading = true; - if (!ec) + self->read_in_progress_ = false; + if (self->shutting_down_) { - bool ret = self->parser_.feed(self->buffer_.data(), bytes_transferred); - if (ret && self->adaptor_.is_open()) - { - error_while_reading = false; - } + return; } - if (error_while_reading) - { - self->cancel_deadline_timer(); - self->parser_.done(); - self->adaptor_.shutdown_read(); - self->adaptor_.close(); - CROW_LOG_DEBUG << self << " from read(1) with description: \"" << http_errno_description(static_cast(self->parser_.http_errno)) << '\"'; - } - else if (self->close_connection_) + if (auto state = self->async_chunk_transfer_) { - self->cancel_deadline_timer(); - self->parser_.done(); - // adaptor will close after write - } - else if (!self->need_to_call_after_handlers_) - { - self->start_deadline(); - self->do_read(); + // Peer watch while a transfer is active: an error aborts + // the stream, extra bytes only mark the connection to + // close after it finishes, and the watch stays armed. + if (ec) + { + self->finish_async_chunked(state, false, true); + return; + } + if (bytes_transferred > 0) + { + // Early pipelined bytes: the stream finishes + // correctly, then the connection closes. The watch + // stays armed so a peer that disconnects while the + // provider is idle is still detected. + self->close_connection_ = true; + self->do_read(); + } + return; } - else + + if (!ec) { - // res will be completed later by user - self->need_to_start_read_after_complete_ = true; + self->process_incoming_input(self->buffer_.data(), bytes_transferred); + return; } + + self->close_after_read_error(); }); } + void process_incoming_input(const char* data, std::size_t length) + { + std::size_t consumed = 0; + const bool parsed = parser_.feed(data, static_cast(length), consumed); + if (parsed && consumed < length) + { + // Pipelined bytes behind a deferred or streamed response: the + // current response finishes, then the connection closes. + close_connection_ = true; + } + + if (!parsed || consumed > length || !adaptor_.is_open()) + { + close_after_read_error(); + } + else if (close_connection_) + { + // An in-flight transfer keeps its write deadline until it + // finishes; close-after-response is decided by the flag alone. + if (!async_chunk_transfer_) + { + cancel_deadline_timer(); + } + parser_.done(); + // adaptor will close after write + } + else if (async_chunk_transfer_) + { + // The transfer owns the connection until it finishes; its + // completion path decides between keep-alive and close. + need_to_start_read_after_complete_ = true; + } + else if (!need_to_call_after_handlers_) + { + start_deadline(); + do_read(); + } + else + { + // res will be completed later by user + need_to_start_read_after_complete_ = true; + } + } + + void close_adaptor() + { + // The flag feeds response::is_alive() from any thread; the socket + // itself is only touched on the connection executor. + peer_open_->store(false); + adaptor_.close(); + } + + void close_after_read_error() + { + cancel_deadline_timer(); + parser_.done(); + adaptor_.shutdown_read(); + close_adaptor(); + CROW_LOG_DEBUG << this << " from read(1) with description: \"" + << http_errno_description(static_cast(parser_.http_errno)) << '\"'; + } + void do_write() { auto self = this->shared_from_this(); @@ -453,7 +1553,7 @@ namespace crow if (self->close_connection_) { self->adaptor_.shutdown_write(); - self->adaptor_.close(); + self->close_adaptor(); CROW_LOG_DEBUG << self << " from write(1)"; } } @@ -490,6 +1590,13 @@ namespace crow void cancel_deadline_timer() { + // task_timer ids are reused once its task map drains; a stale id + // must never cancel another connection's timer. + if (!deadline_armed_) + { + return; + } + deadline_armed_ = false; CROW_LOG_DEBUG << this << " timer cancelled: " << &task_timer_ << ' ' << task_id_; task_timer_.cancel(task_id_); } @@ -500,13 +1607,15 @@ namespace crow auto self = this->shared_from_this(); task_id_ = task_timer_.schedule([self] { + self->deadline_armed_ = false; if (!self->adaptor_.is_open()) { return; } self->adaptor_.shutdown_readwrite(); - self->adaptor_.close(); + self->close_adaptor(); }); + deadline_armed_ = true; CROW_LOG_DEBUG << this << " timer added: " << &task_timer_ << ' ' << task_id_; } @@ -522,6 +1631,9 @@ namespace crow response res; bool close_connection_ = false; + // Read by response::is_alive() from any thread; written on the + // connection executor when the socket closes. + std::shared_ptr> peer_open_{std::make_shared>(true)}; const std::string& server_name_; std::vector buffers_; @@ -529,6 +1641,7 @@ namespace crow std::string content_length_; std::string date_str_; std::string res_body_copy_; + std::shared_ptr async_chunk_transfer_; detail::task_timer::identifier_type task_id_{}; @@ -536,6 +1649,10 @@ namespace crow bool need_to_call_after_handlers_{}; bool need_to_start_read_after_complete_{}; bool add_keep_alive_{}; + bool read_in_progress_{}; + bool deadline_armed_{}; + bool shutting_down_{}; + bool lifecycle_tracked_{}; std::tuple* middlewares_; detail::context ctx_; @@ -544,8 +1661,11 @@ namespace crow detail::task_timer& task_timer_; size_t res_stream_threshold_; + uint8_t stream_idle_timeout_; + size_t max_stream_chunk_size_; std::atomic& queue_length_; + std::weak_ptr lifecycle_registry_; }; } // namespace crow diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 71a9e0c7a9..036785870d 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -1,4 +1,8 @@ #pragma once +#include +#include +#include +#include #include #include #include @@ -35,6 +39,14 @@ namespace crow class Router; + /// Outcome of a single chunk provider invocation. + enum class chunk_result + { + more, ///< The chunk is valid and more data is coming. + done, ///< The chunk is valid and it is the last one; the terminating frame is sent. + abort ///< The body cannot be finished; the connection is closed without the terminating frame. + }; + /// HTTP response struct response { @@ -46,6 +58,19 @@ namespace crow friend class Router; + private: + struct deferred_response_lifecycle + { + std::mutex mutex; + }; + + enum class body_source_kind + { + none, + chunked + }; + + public: int code{200}; ///< The Status code for the response. std::string body; ///< The actual payload containing the response data. ci_map headers; ///< HTTP headers. @@ -56,6 +81,54 @@ namespace crow bool skip_body = false; ///< Whether this is a response to a HEAD request. bool manual_length_header = false; ///< Whether Crow should automatically add a "Content-Length" header. + /// Provider of the response body, called repeatedly until it returns false. + + /// + /// The provider fills the given string with the next chunk of the body and returns + /// `true` while more data is coming, `false` on its last invocation. It runs on the + /// connection's executor and must return promptly: blocking stalls every connection + /// on that worker along with peer-disconnect detection and the stream timers. An + /// empty chunk sends nothing, but each empty result returned with `true` immediately + /// schedules the next invocation, so do not poll with empty chunks; when the next + /// chunk may not be ready at call time, use an asynchronous provider instead. + using chunk_provider_t = std::function; + + /// Outcome of a single chunk provider invocation; see crow::chunk_result. + using chunk_result = crow::chunk_result; + + /// Provider of the response body, called repeatedly until it returns `done` or `abort`. + + /// + /// The provider fills the given string with the next chunk of the body and returns + /// a chunk_result describing how to proceed. It runs on the connection's executor + /// and must return promptly: blocking stalls every connection on that worker along + /// with peer-disconnect detection and the stream timers. An empty chunk sends + /// nothing, but each empty `more` result immediately schedules the next invocation, + /// so do not poll with empty chunks; when the next chunk may not be ready at call + /// time, use an asynchronous provider instead. + using chunk_provider_ex_t = std::function; + + /// Completion callback for one asynchronous chunk provider invocation. + + /// + /// Returns `true` when Crow accepts the result for publication on the + /// connection executor. Returns `false` for inactive or repeated results and + /// when publication fails. A provider may use a `false` result to stop its + /// source after attempting to publish `chunk_result::more`. + using async_chunk_completion_t = std::function; + + /// Provider that asynchronously supplies one response body chunk per invocation. + using async_chunk_provider_t = std::function; + + /// Handler called once after the chunked body has been written (or writing has stopped). + + /// + /// `clean` is `true` when the provider finished normally and every write succeeded. + /// It is `false` after a provider abort or exception, publication failure, peer + /// closure, a write failure or timeout, an oversized or overdue chunk, or server + /// shutdown. + using chunk_complete_t = std::function; + /// Set the value of an existing header in the response. void set_header(std::string key, std::string value) { @@ -181,8 +254,31 @@ namespace crow body = std::move(r.body); code = r.code; headers = std::move(r.headers); - completed_ = r.completed_; + completed_ = r.completed_.load(); file_info = std::move(r.file_info); +#ifdef CROW_ENABLE_COMPRESSION + compressed = r.compressed; +#endif + // skip_body is deliberately not copied: it marks the request side (the router + // sets it on the connection's response before the handler runs for a HEAD + // request), so a handler assigning a freshly built response must not reset it. + manual_length_header = r.manual_length_header; + async_chunk_provider_ = std::move(r.async_chunk_provider_); + // The completion handler transfers the way set_chunked_completion_handler() + // installs one: a source that carries a handler replaces this one, a source + // that carries none leaves this one in place. A freshly built response + // assigned over a response that streams (the default exception handler does + // exactly that) is not a request to drop the pending release callback: the + // write that replaces the stream reports through it instead, the same way a + // static file configured over a provider already does. + if (r.chunk_complete_) + { + chunk_complete_ = std::move(r.chunk_complete_); + } + body_source_ = r.body_source_; + r.async_chunk_provider_ = nullptr; + r.chunk_complete_ = nullptr; + r.body_source_ = body_source_kind::none; return *this; } @@ -199,6 +295,10 @@ namespace crow headers.clear(); completed_ = false; file_info = static_file_info{}; + async_chunk_provider_ = nullptr; + chunk_complete_ = nullptr; + body_source_ = body_source_kind::none; + manual_length_header = false; } /// Return a "Temporary Redirect" response. @@ -249,20 +349,64 @@ namespace crow /// Set the response completion flag and call the handler (to send the response). void end() { + // A connection-owned response is finalized on the connection's + // executor; any thread may request that, and only the first call + // takes effect. The local reference keeps the lifecycle mutex + // alive even if the handler releases the owning connection. + auto lifecycle = deferred_lifecycle_; + if (lifecycle) + { + std::function completion_handler; + { + std::lock_guard lifecycle_lock(lifecycle->mutex); + if (completed_.exchange(true)) + { + return; + } + completion_handler = std::move(complete_request_handler_); + complete_request_handler_ = nullptr; + } + if (completion_handler) + { + completion_handler(); + } + return; + } if (!completed_) { completed_ = true; if (skip_body) { - set_header("Content-Length", std::to_string(body.size())); - body = ""; - manual_length_header = true; + if (is_chunked_type()) + { + // HEAD keeps "Transfer-Encoding: chunked" and omits "Content-Length". + async_chunk_provider_ = nullptr; + body = ""; + manual_length_header = true; + if (!complete_request_handler_ && !is_alive_helper_) + { + notify_chunked_completion(true); + } + } + else + { + if (!is_static_type() && !body.empty()) + { + set_header("Content-Length", std::to_string(body.size())); + manual_length_header = true; + } + body = ""; + if (is_static_type()) + { + manual_length_header = true; + } + } } - if (complete_request_handler_) + auto completion_handler = std::move(complete_request_handler_); + complete_request_handler_ = nullptr; + if (completion_handler) { - complete_request_handler_(); - manual_length_header = false; - skip_body = false; + completion_handler(); } } } @@ -270,14 +414,55 @@ namespace crow /// Same as end() except it adds a body part right before ending. void end(const std::string& body_part) { + // The latch and the body append form one critical section, so + // concurrent calls deliver exactly one body; the lock is released + // before the handler runs. + auto lifecycle = deferred_lifecycle_; + if (lifecycle) + { + std::function completion_handler; + { + std::lock_guard lifecycle_lock(lifecycle->mutex); + if (completed_) + { + return; + } + // Append before latching: a throwing append leaves the + // response incomplete instead of completed-but-stuck. + body += body_part; + completed_ = true; + completion_handler = std::move(complete_request_handler_); + complete_request_handler_ = nullptr; + } + if (completion_handler) + { + completion_handler(); + } + return; + } body += body_part; end(); } /// Check if the connection is still alive (usually by checking the socket status). + /// + /// Callable from any thread until end(); the helper reads shared + /// atomic connection state, and its swap is serialized with the + /// connection through the lifecycle mutex. bool is_alive() { - return is_alive_helper_ && is_alive_helper_(); + std::function helper; + auto lifecycle = deferred_lifecycle_; + if (lifecycle) + { + std::lock_guard lifecycle_lock(lifecycle->mutex); + helper = is_alive_helper_; + } + else + { + helper = is_alive_helper_; + } + return helper && helper(); } /// Check whether the response has a static file defined. @@ -286,6 +471,106 @@ namespace crow return file_info.path.size(); } + /// Check whether the response body is produced by a chunk provider. + bool is_chunked_type() const + { + return body_source_ != body_source_kind::none; + } + + /// Send the response body in chunks produced on demand, without holding it in memory. + + /// + /// The body is sent using `Transfer-Encoding: chunked`, so its size need not be known + /// in advance, which makes it suitable for bodies of arbitrary or unknown length. The + /// provider runs on the connection's executor while the response is being written and + /// must return promptly (see chunk_provider_t). The provider should not throw: an + /// exception that escapes it is logged and treated as an abort (the connection is + /// closed without the terminating frame). + void set_chunked_content_provider(chunk_provider_t provider, std::string content_type = "") + { + set_chunked_content_provider( + [provider = std::move(provider)](std::string& chunk) { + return provider(chunk) ? chunk_result::more : chunk_result::done; + }, + std::move(content_type)); + } + + /// Send the response body in chunks produced on demand, without holding it in memory. + + /// + /// Same as the `chunk_provider_t` overload, except that the provider can also return + /// `chunk_result::abort` to close the connection without the terminating frame, so + /// that the client sees a truncated body instead of a seemingly complete one. + /// Any previously set "Content-Length" header is removed: chunked transfer encoding + /// and "Content-Length" must not be sent together. A previously configured static + /// file or string body is discarded for the same reason: a response has exactly one + /// body source, and the one configured last wins. + void set_chunked_content_provider(chunk_provider_ex_t provider, std::string content_type = "") + { + // Reuse the asynchronous write path so socket backpressure never + // blocks a worker. + async_chunk_provider_ = [provider = std::move(provider)](async_chunk_completion_t complete) { + std::string chunk; + const auto result = provider(chunk); + complete(result, std::move(chunk)); + }; + body_source_ = body_source_kind::chunked; + file_info = static_file_info{}; + body.clear(); + manual_length_header = true; + headers.erase("Content-Length"); + set_header("Transfer-Encoding", "chunked"); + if (!content_type.empty()) { + set_header("Content-Type", std::move(content_type)); + } + } + + /// Send response body chunks supplied asynchronously without holding the body in memory. + + /// + /// Crow invokes the provider once for each requested chunk. The provider must return + /// promptly and invoke its completion callback exactly once with `more`, `done`, or + /// `abort`, either before or after it returns. The callback may be invoked from any + /// thread; Crow posts the result to the connection executor. The next chunk is not + /// requested until the preceding chunk has been written. An exception that escapes the + /// provider before it reports a result is treated as `abort`; a result that was already + /// reported stands. Installing this provider discards a string body, static file, + /// or synchronous chunk provider that was configured earlier. An empty provider still + /// selects asynchronous streaming; Crow treats its invocation as an abort, closes the + /// connection without a terminating frame, and reports unclean completion once. + void set_async_chunked_content_provider(async_chunk_provider_t provider, std::string content_type = "") { + async_chunk_provider_ = std::move(provider); + body_source_ = body_source_kind::chunked; + file_info = static_file_info{}; + body.clear(); + manual_length_header = true; + headers.erase("Content-Length"); + set_header("Transfer-Encoding", "chunked"); + if (!content_type.empty()) + { + set_header("Content-Type", std::move(content_type)); + } + } + + /// Set a handler called once after the chunked body has been written (or writing has stopped). + + /// + /// The handler runs exactly once. Its `clean` argument is `true` when the provider + /// finished normally (`chunk_result::done`, or `false` from the `chunk_provider_t` + /// overload) and every write succeeded. It is `false` after `abort`, a provider + /// exception, a write error, or shutdown during an active asynchronous transfer. Normal + /// completion and server worker shutdown invoke it on the connection thread. For a HEAD + /// request the body is skipped and the provider is never called, but the handler still + /// runs with `clean == true`. An HTTP/1.0 request rejects either kind of provider before + /// invocation and runs the handler with `clean == false`. Move-assigning another + /// response over this one follows this setter: a source that carries a handler + /// replaces this one, a source that carries none leaves this one in place. The + /// handler should not throw: an exception that escapes it is logged and swallowed. + void set_chunked_completion_handler(chunk_complete_t handler) + { + chunk_complete_ = std::move(handler); + } + /// This constains metadata (coming from the `stat` command) related to any static files associated with this response. /// @@ -308,6 +593,14 @@ namespace crow /// the content_type may be specified explicitly. void set_static_file_info_unsafe(std::string path, std::string content_type = "") { + // A response has exactly one body source: installing the file drops a + // previously configured chunk provider together with its framing header, + // otherwise "Transfer-Encoding: chunked" and "Content-Length" would be + // sent side by side while the raw file bytes go out unframed. + async_chunk_provider_ = nullptr; + body_source_ = body_source_kind::none; + headers.erase("Transfer-Encoding"); + manual_length_header = false; file_info.path = path; file_info.statResult = stat(file_info.path.c_str(), &file_info.statbuf); #ifdef CROW_ENABLE_COMPRESSION @@ -392,9 +685,10 @@ namespace crow {status::BAD_GATEWAY, "HTTP/1.1 502 Bad Gateway\r\n"}, {status::SERVICE_UNAVAILABLE, "HTTP/1.1 503 Service Unavailable\r\n"}, {status::GATEWAY_TIMEOUT, "HTTP/1.1 504 Gateway Timeout\r\n"}, + {status::HTTP_VERSION_NOT_SUPPORTED, "HTTP/1.1 505 HTTP Version Not Supported\r\n"}, {status::VARIANT_ALSO_NEGOTIATES, "HTTP/1.1 506 Variant Also Negotiates\r\n"}, - {status::WEBDAV_INSUFFICIENT_STORAGE, "HTTP/1.1 507 Insufficient Storage\r\n"}, - }; + {status::WEBDAV_INSUFFICIENT_STORAGE, "HTTP/1.1 507 Insufficient Storage\r\n"}, + }; static const std::string seperator = ": "; @@ -446,7 +740,7 @@ namespace crow buffers.emplace_back(date_str_.data(), date_str_.size()); buffers.emplace_back(crlf.data(), crlf.size()); }*/ - if (add_keep_alive) + if (add_keep_alive && !headers.count("connection")) { static std::string keep_alive_tag = "Connection: Keep-Alive"; buffers.emplace_back(keep_alive_tag.data(), keep_alive_tag.size()); @@ -456,9 +750,43 @@ namespace crow buffers.emplace_back(crlf.data(), crlf.size()); } - bool completed_{}; + std::atomic completed_{}; std::function complete_request_handler_; std::function is_alive_helper_; + std::shared_ptr deferred_lifecycle_; static_file_info file_info; + async_chunk_provider_t async_chunk_provider_; + chunk_complete_t chunk_complete_; + + private: + void notify_chunked_completion(bool clean) noexcept + { + auto completion_handler = std::move(chunk_complete_); + chunk_complete_ = nullptr; + invoke_chunked_completion(std::move(completion_handler), clean); + } + + static void invoke_chunked_completion(chunk_complete_t completion_handler, bool clean) noexcept + { + if (!completion_handler) + { + return; + } + + try + { + completion_handler(clean); + } + catch (const std::exception& e) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler: " << e.what(); + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler."; + } + } + + body_source_kind body_source_{body_source_kind::none}; }; } // namespace crow diff --git a/include/crow/http_server.h b/include/crow/http_server.h index c04e4f1582..4eb5ae1ab5 100644 --- a/include/crow/http_server.h +++ b/include/crow/http_server.h @@ -131,8 +131,10 @@ namespace crow // NOTE: Already documented in "crow/app.h" } uint16_t worker_thread_count = concurrency_ - 1; - for (int i = 0; i < worker_thread_count; i++) + for (int i = 0; i < worker_thread_count; i++) { io_context_pool_.emplace_back(new asio::io_context()); + connection_lifecycle_registry_pool_.emplace_back(std::make_shared()); + } get_cached_date_str_pool_.resize(worker_thread_count); task_timer_pool_.resize(worker_thread_count); @@ -191,6 +193,9 @@ namespace crow // NOTE: Already documented in "crow/app.h" CROW_LOG_ERROR << "Worker Crash: An uncaught exception occurred: " << e.what(); } } + // run() has stopped dispatching, but this is still the connection + // worker. Finish tracked transfers before the worker future becomes ready. + connection_lifecycle_registry_pool_[i]->shutdown_all(); })); if (tick_function_ && tick_interval_.count() > 0) @@ -311,9 +316,16 @@ namespace crow // NOTE: Already documented in "crow/app.h" size_t context_idx = pick_io_context_idx(); asio::io_context& ic = *io_context_pool_[context_idx]; auto p = std::make_shared>( - ic, handler_, server_name_, middlewares_, - get_cached_date_str_pool_[context_idx], *task_timer_pool_[context_idx], adaptor_ctx_, task_queue_length_pool_[context_idx]); - + ic, + handler_, + server_name_, + middlewares_, + get_cached_date_str_pool_[context_idx], + *task_timer_pool_[context_idx], + adaptor_ctx_, + task_queue_length_pool_[context_idx], + connection_lifecycle_registry_pool_[context_idx]); + CROW_LOG_DEBUG << &ic << " {" << context_idx << "} queue length: " << task_queue_length_pool_[context_idx]; acceptor_.raw_acceptor().async_accept( @@ -346,6 +358,7 @@ namespace crow // NOTE: Already documented in "crow/app.h" std::vector> io_context_pool_; asio::io_context io_context_; std::vector task_timer_pool_; + std::vector> connection_lifecycle_registry_pool_; std::vector> get_cached_date_str_pool_; Acceptor acceptor_; bool shutting_down_ = false; diff --git a/include/crow/parser.h b/include/crow/parser.h index 1417d6cb60..0ed9aebb4d 100644 --- a/include/crow/parser.h +++ b/include/crow/parser.h @@ -97,7 +97,7 @@ namespace crow self->message_complete = true; self->process_message(); - return 0; + return self->stop_after_message_ ? 1 : 0; } HTTPParser(Handler* handler): http_parser(), @@ -108,10 +108,17 @@ namespace crow // return false on error /// Parse a buffer into the different sections of an HTTP request. - bool feed(const char* buffer, int length) + + /// `consumed` receives the exact number of input bytes processed. It can be + /// smaller than `length` after stop_after_message() requests a successful stop + /// at the current message boundary. + bool feed(const char* buffer, int length, std::size_t& consumed) { if (message_complete) + { + consumed = static_cast(length); return true; + } const static http_parser_settings settings_{ on_message_begin, @@ -124,12 +131,28 @@ namespace crow on_message_complete, }; - int nparsed = http_parser_execute(this, &settings_, buffer, length); + const std::size_t nparsed = http_parser_execute(this, &settings_, buffer, length); + consumed = nparsed; + if (stop_after_message_ && message_complete && http_errno == CHPE_CB_message_complete) + { + // A nonzero completion callback is the parser's existing mechanism for + // stopping at the current byte. Treat this requested message boundary as + // a successful partial parse and leave the remainder to the caller. + http_errno = CHPE_OK; + return true; + } if (http_errno != CHPE_OK) { return false; } - return nparsed == length; + return nparsed == static_cast(length); + } + + /// Parse a complete input buffer without requesting the consumed-byte count. + bool feed(const char* buffer, int length) + { + std::size_t consumed = 0; + return feed(buffer, length, consumed) && consumed == static_cast(length); } bool done() @@ -145,9 +168,16 @@ namespace crow header_building_state = 0; qs_point = 0; message_complete = false; + stop_after_message_ = false; state = CROW_NEW_MESSAGE(); } + /// Stop after the current message and report the exact consumed-byte boundary. + void stop_after_message() + { + stop_after_message_ = true; + } + inline void process_url() { handler_->handle_url(); @@ -190,6 +220,7 @@ namespace crow private: int header_building_state = 0; bool message_complete = false; + bool stop_after_message_ = false; std::string header_field; std::string header_value; diff --git a/include/crow/routing.h b/include/crow/routing.h index 42ac0a78f9..f718381f3f 100644 --- a/include/crow/routing.h +++ b/include/crow/routing.h @@ -1611,6 +1611,7 @@ namespace crow // NOTE: Already documented in "crow/app.h" std::unique_ptr handle_initial(request& req, response& res) { HTTPMethod method_actual = req.method; + res.skip_body = req.method == HTTPMethod::Head; std::unique_ptr found{ new routing_handle_result( @@ -1639,7 +1640,6 @@ namespace crow // NOTE: Already documented in "crow/app.h" } } - res.skip_body = true; found->method = method_actual; return found; } @@ -1803,15 +1803,43 @@ namespace crow // NOTE: Already documented in "crow/app.h" return; } - res.complete_request_handler_ = [&rule, &ctx, &container, &req, &res, glob_completion_handler] { - detail::middleware_call_criteria_dynamic crit_bwd(rule.mw_indices_.indices()); - - detail::after_handlers_call_helper< - decltype(crit_bwd), - std::tuple_size::value - 1, - typename App::context_t, - typename App::mw_container_t>(crit_bwd, container, ctx, req, res); - glob_completion_handler(); + // Route-local after handlers touch the request, the response, + // and the middleware context, so they must run on the + // connection executor even when end() is called from a foreign + // thread; a throwing after handler must not swallow the + // completion. The executor pointer is captured now, while the + // request is still being routed on that executor. + asio::io_context* handler_io_context = req.io_context; + res.complete_request_handler_ = [&rule, &ctx, &container, &req, &res, glob_completion_handler, handler_io_context] { + auto run_after_handlers = [&rule, &ctx, &container, &req, &res, glob_completion_handler] { + detail::middleware_call_criteria_dynamic crit_bwd(rule.mw_indices_.indices()); + + try + { + detail::after_handlers_call_helper< + decltype(crit_bwd), + std::tuple_size::value - 1, + typename App::context_t, + typename App::mw_container_t>(crit_bwd, container, ctx, req, res); + } + catch (const std::exception& e) + { + CROW_LOG_ERROR << "An uncaught exception occurred in a route-local after handler: " << e.what(); + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in a route-local after handler."; + } + glob_completion_handler(); + }; + if (handler_io_context) + { + asio::dispatch(*handler_io_context, std::move(run_after_handlers)); + } + else + { + run_after_handlers(); + } }; } rule.handle(req, res, rp); diff --git a/mkdocs.yml b/mkdocs.yml index e5013dbb99..d2aa3e8ee6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - SBOM Generation: guides/sbom.md - SSL: guides/ssl.md - Static Files: guides/static.md + - Streaming: guides/streaming.md - Blueprints: guides/blueprints.md - Compression: guides/compression.md - Websockets: guides/websockets.md diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 00db73a7be..436435f48b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -22,6 +22,7 @@ enable_testing() # list the test sources set(TEST_SRCS unittest.cpp + streaming_tests.cpp query_string_tests.cpp unit_tests/test_http_response.cpp unit_tests/test_json.cpp @@ -32,6 +33,7 @@ set(TEST_SRCS ) add_executable(unittest ${TEST_SRCS}) +target_compile_definitions(unittest PRIVATE CROW_ENABLE_ASYNC_CHUNK_PUBLICATION_TEST_HOOK) target_link_libraries(unittest Crow::Crow Catch2::Catch2WithMain) add_warnings_optimizations(unittest) add_sanitizer_flags(unittest) diff --git a/tests/streaming_tests.cpp b/tests/streaming_tests.cpp new file mode 100644 index 0000000000..9e6a6a49dc --- /dev/null +++ b/tests/streaming_tests.cpp @@ -0,0 +1,5294 @@ +#define CROW_ENABLE_DEBUG +#define CROW_LOG_LEVEL 0 +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "catch2/catch_all.hpp" +#include "crow.h" + +#include "test_common.h" + +using namespace std; +using namespace crow; + + +#ifdef CROW_ENABLE_ASYNC_CHUNK_PUBLICATION_TEST_HOOK +namespace crow { namespace detail { +static std::function async_chunk_publication_test_hook; + +void invoke_async_chunk_publication_test_hook() { + if (async_chunk_publication_test_hook) + async_chunk_publication_test_hook(); +} +}} // namespace crow::detail + +class ScopedAsyncChunkPublicationTestHook { +public: + explicit ScopedAsyncChunkPublicationTestHook(std::function hook) { + crow::detail::async_chunk_publication_test_hook = std::move(hook); + } + + ~ScopedAsyncChunkPublicationTestHook() { + crow::detail::async_chunk_publication_test_hook = nullptr; + } + + ScopedAsyncChunkPublicationTestHook(const ScopedAsyncChunkPublicationTestHook&) = delete; + ScopedAsyncChunkPublicationTestHook& operator=(const ScopedAsyncChunkPublicationTestHook&) = delete; +}; +#endif + +bool has_chunk_terminator(const std::string& response) +{ + return response.size() >= 5 && response.compare(response.size() - 5, 5, "0\r\n\r\n") == 0; +} + +bool has_complete_http_response(const std::string& response) { + const auto header_end = response.find("\r\n\r\n"); + if (header_end == std::string::npos) + return false; + + const std::string content_length_name = "Content-Length:"; + const auto content_length_header = response.find(content_length_name); + if (content_length_header == std::string::npos || content_length_header > header_end) + return false; + + auto value = content_length_header + content_length_name.size(); + while (value < header_end && response[value] == ' ') + ++value; + if (value == header_end || response[value] < '0' || response[value] > '9') + return false; + + std::size_t content_length = 0; + while (value < header_end && response[value] >= '0' && response[value] <= '9') { + content_length = content_length * 10 + static_cast(response[value] - '0'); + ++value; + } + + return response.size() >= header_end + 4 + content_length; +} + +class PausingSocketContext { +public: + void observe_next_read() + { + observe_next_read_.store(true); + } + + bool report_read_started() + { + if (!observe_next_read_.exchange(false)) + return false; + + observed_read_started_.set_value(); + return true; + } + + void report_read_completed(std::size_t bytes_transferred) + { + observed_read_completed_.set_value(bytes_transferred); + } + + std::future observed_read_started_future() + { + return observed_read_started_.get_future(); + } + + std::future observed_read_completed_future() + { + return observed_read_completed_.get_future(); + } + + void pause_read_completion_after(std::size_t byte_count) + { + observed_read_bytes_.store(0); + pause_read_completion_after_.store(byte_count); + } + + bool take_read_completion_pause(std::size_t bytes_transferred) + { + const auto total = observed_read_bytes_.fetch_add(bytes_transferred) + bytes_transferred; + const auto limit = pause_read_completion_after_.load(); + if (limit == 0 || total <= limit) + return false; + + pause_read_completion_after_.store(0); + return true; + } + + void set_pending_read(std::function resume) + { + { + std::lock_guard lock(mutex_); + resume_read_ = std::move(resume); + } + read_pending_.set_value(); + } + + void resume_pending_read() + { + std::function resume; + { + std::lock_guard lock(mutex_); + resume = std::move(resume_read_); + } + if (resume) + resume(); + } + + std::future pending_read_future() + { + return read_pending_.get_future(); + } + + void pause_next_write() { + pause_next_write_.store(true); + } + + bool take_pause_request() { + return pause_next_write_.exchange(false); + } + + void fail_next_write() { + fail_next_write_.store(true); + } + + bool take_failure_request() { + return fail_next_write_.exchange(false); + } + + void set_pending_write(std::function resume) { + { + std::lock_guard lock(mutex_); + resume_write_ = std::move(resume); + } + write_pending_.set_value(); + } + + void resume_pending_write() { + std::function resume; + { + std::lock_guard lock(mutex_); + resume = std::move(resume_write_); + } + if (resume) + resume(); + } + + void discard_pending_write() { + std::function pending_write; + { + std::lock_guard lock(mutex_); + pending_write = std::move(resume_write_); + } + } + + std::future pending_write_future() { + return write_pending_.get_future(); + } + + std::future started_connection_destroyed_future() { + return started_connection_destroyed_.get_future(); + } + + void report_started_connection_destroyed() { + if (!started_connection_destruction_reported_.exchange(true)) + started_connection_destroyed_.set_value(); + } + +private: + std::atomic observe_next_read_{false}; + std::atomic observed_read_bytes_{0}; + std::atomic pause_read_completion_after_{0}; + std::atomic pause_next_write_{false}; + std::atomic fail_next_write_{false}; + std::atomic started_connection_destruction_reported_{false}; + std::promise observed_read_started_; + std::promise observed_read_completed_; + std::promise read_pending_; + std::promise write_pending_; + std::promise started_connection_destroyed_; + std::mutex mutex_; + std::function resume_write_; + std::function resume_read_; +}; + +class PausingSocketAdaptor : public crow::SocketAdaptor { +public: + using context = PausingSocketContext; + using executor_type = asio::ip::tcp::socket::executor_type; + + PausingSocketAdaptor(asio::io_context& io_context, context* socket_context): + crow::SocketAdaptor(io_context, nullptr), context_(socket_context) + { + } + + ~PausingSocketAdaptor() { + if (started_ && context_) + context_->report_started_connection_destroyed(); + } + + executor_type get_executor() noexcept { + return socket_.get_executor(); + } + + asio::io_context& get_io_context() { + return GET_IO_CONTEXT(socket_); + } + + asio::ip::tcp::socket& raw_socket() { + return socket_; + } + + PausingSocketAdaptor& socket() { + return *this; + } + + asio::ip::tcp::endpoint remote_endpoint() const { + return socket_.remote_endpoint(); + } + + std::string address() const { + return socket_.remote_endpoint().address().to_string(); + } + + bool is_open() const { + return socket_.is_open(); + } + + void close() { + asio_error_code ec; + socket_.close(ec); + } + + void shutdown_readwrite() { + asio_error_code ec; + socket_.shutdown(asio::socket_base::shutdown_both, ec); + } + + void shutdown_write() { + asio_error_code ec; + socket_.shutdown(asio::socket_base::shutdown_send, ec); + } + + void shutdown_read() { + asio_error_code ec; + socket_.shutdown(asio::socket_base::shutdown_receive, ec); + } + + template + void start(F complete) { + started_ = true; + complete(asio_error_code()); + } + + template + void async_read_some(const MutableBufferSequence& buffers, ReadHandler&& handler) { + const bool observed = context_ && context_->report_read_started(); + auto* io_context = &get_io_context(); + socket_.async_read_some( + buffers, + [context = context_, io_context, observed, handler = std::forward(handler)]( + const asio_error_code& ec, std::size_t bytes_transferred) mutable { + if (observed) + context->report_read_completed(bytes_transferred); + if (context && context->take_read_completion_pause(bytes_transferred)) + { + context->set_pending_read( + [io_context, handler = std::move(handler), ec, bytes_transferred]() mutable { + asio::post(*io_context, + [handler = std::move(handler), ec, bytes_transferred]() mutable { + handler(ec, bytes_transferred); + }); + }); + return; + } + handler(ec, bytes_transferred); + }); + } + + template + std::size_t write_some(const ConstBufferSequence& buffers) { + return socket_.write_some(buffers); + } + + template + std::size_t write_some(const ConstBufferSequence& buffers, asio_error_code& ec) { + if (context_ && context_->take_failure_request()) { + ec = asio::error::operation_aborted; + return 0; + } + return socket_.write_some(buffers, ec); + } + + template + void async_write_some(const ConstBufferSequence& buffers, WriteHandler&& handler) { + if (context_ && context_->take_failure_request()) { + auto copied_handler + = std::make_shared::type>(std::forward(handler)); + asio::post(get_io_context(), [copied_handler]() mutable { + const asio_error_code ec = asio::error::operation_aborted; + (*copied_handler)(ec, 0); + }); + return; + } + + if (!context_ || !context_->take_pause_request()) { + socket_.async_write_some(buffers, std::forward(handler)); + return; + } + + auto copied_buffers = std::make_shared>(); + for (auto iterator = asio::buffer_sequence_begin(buffers); iterator != asio::buffer_sequence_end(buffers); + ++iterator) + copied_buffers->emplace_back(*iterator); + auto copied_handler + = std::make_shared::type>(std::forward(handler)); + + context_->set_pending_write([this, copied_buffers, copied_handler]() mutable { + asio::post(get_io_context(), [this, copied_buffers, copied_handler]() mutable { + socket_.async_write_some(*copied_buffers, std::move(*copied_handler)); + }); + }); + } + +private: + context* context_; + bool started_{false}; +}; + +class ChunkCompletionObservation { +public: + std::future first_result() { + return first_result_.get_future(); + } + + std::future first_thread() { + return first_thread_.get_future(); + } + + void record(bool clean) { + if (calls_.fetch_add(1) == 0) { + first_thread_.set_value(std::this_thread::get_id()); + first_result_.set_value(clean); + } + } + + std::size_t calls() const { + return calls_.load(); + } + +private: + std::atomic calls_{0}; + std::promise first_thread_; + std::promise first_result_; +}; + +class DeferredChunkCompletion +{ +public: + std::future_status wait_for(std::chrono::milliseconds timeout) + { + return captured_.wait_for(timeout); + } + + void capture(crow::response::async_chunk_completion_t complete) + { + if (capture_reported_.exchange(true)) + { + return; + } + + { + std::lock_guard lock(mutex_); + complete_ = std::move(complete); + } + captured_promise_.set_value(); + } + + bool complete(crow::chunk_result result, std::string chunk) + { + crow::response::async_chunk_completion_t complete; + { + std::lock_guard lock(mutex_); + complete = std::move(complete_); + } + if (!complete) + { + return false; + } + + return complete(result, std::move(chunk)); + } + +private: + std::promise captured_promise_; + std::future captured_{captured_promise_.get_future()}; + std::atomic capture_reported_{false}; + std::mutex mutex_; + crow::response::async_chunk_completion_t complete_; +}; + +struct PipelinedAsyncObservation +{ + std::atomic first_route_calls{0}; + std::atomic provider_calls{0}; + std::atomic second_route_calls{0}; + std::atomic first_completion_seen{false}; + std::atomic second_route_overlapped{false}; + std::atomic first_body_correct{true}; + DeferredChunkCompletion provider_completion; + ChunkCompletionObservation completion; +}; + +class LifecycleRegistryProbe +{ +public: + void shutdown_on_worker_exit() noexcept + { + shutdown_calls_.fetch_add(1); + } + + std::size_t shutdown_calls() const + { + return shutdown_calls_.load(); + } + +private: + std::atomic shutdown_calls_{0}; +}; + +namespace crow +{ + struct connection_test_access + { + template + static response& res(Connection& connection) + { + return connection.res; + } + }; +} // namespace crow + + +TEST_CASE("stream_response") +{ + SimpleApp app; + + + const std::string keyword_ = "hello"; + const size_t repetitions = 250000; + const size_t key_response_size = keyword_.length() * repetitions; + + std::string key_response; + + for (size_t i = 0; i < repetitions; i++) + key_response += keyword_; + + CROW_ROUTE(app, "/test") + ([&key_response](const crow::request&, crow::response& res) { + res.body = key_response; + res.end(); + }); + + app.validate(); + + // running the test on a separate thread to allow the client to sleep + std::thread runTest([&app, &key_response, key_response_size, keyword_]() { + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + asio::io_context io_context; + std::string sendmsg; + + //Total bytes received + unsigned int received = 0; + sendmsg = "GET /test HTTP/1.0\r\n\r\n"; + { + asio::streambuf b; + + asio::ip::tcp::socket c(io_context); + c.connect(asio::ip::tcp::endpoint( + asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + c.send(asio::buffer(sendmsg)); + + // consuming the headers, since we don't need those for the test + static char buf[2048]; + size_t received_headers_bytes = 0; + + // Magic number is 102. It's the size of the headers, which is at + // least how much we need to read. Since the header size may change + // and break the test, we read twice as much as the header and + // search in the received data for the first occurrence of keyword_. + const size_t headers_bytes_and_some = 102 * 2; + while (received_headers_bytes < headers_bytes_and_some) + received_headers_bytes += c.receive(asio::buffer(buf + received_headers_bytes, + sizeof(buf) / sizeof(buf[0]) - received_headers_bytes)); + + const std::string::size_type header_end_pos = std::string(buf, received_headers_bytes).find(keyword_); + received += received_headers_bytes - header_end_pos; // add any extra that might have been received to the proper received count + + while (received < key_response_size) + { + asio::streambuf::mutable_buffers_type bufs = b.prepare(16384); + + size_t n(0); + n = c.receive(bufs); + b.commit(n); + received += n; + + std::istream istream(&b); + std::string s; + istream >> s; + + CHECK(key_response.substr(received - n, n) == s); + } + } + app.stop(); + }); + runTest.join(); +} // stream_response + + +TEST_CASE("chunked_response") +{ + SimpleApp app; + + CROW_ROUTE(app, "/chunks") + ([](const crow::request&, crow::response& res) { + int remaining = 3; + res.set_chunked_content_provider( + [remaining](std::string& chunk) mutable -> bool { + if (remaining == 0) + return false; + chunk = "part" + std::to_string(4 - remaining); + --remaining; + return true; + }, + "text/plain"); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + CHECK(response.find("Content-Type: text/plain") != std::string::npos); + CHECK(response.find("5\r\npart1\r\n") != std::string::npos); + CHECK(response.find("5\r\npart2\r\n") != std::string::npos); + CHECK(response.find("5\r\npart3\r\n") != std::string::npos); + + // The connection is kept alive after a chunked response: a second request on + // the same connection is served, so the connection went back to reading state. + client.send("GET /chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string second; + while (second.size() < 5 || second.compare(second.size() - 5, 5, "0\r\n\r\n") != 0) + second += client.receive(); + CHECK(second.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(second.find("5\r\npart1\r\n") != std::string::npos); + CHECK(second.find("5\r\npart3\r\n") != std::string::npos); + + app.stop(); +} // chunked_response + + +TEST_CASE("chunked_response_canonicalizes_framing_headers") +{ + SimpleApp app; + + CROW_ROUTE(app, "/late-framing-headers") + ([](const crow::request&, crow::response& res) { + res.set_chunked_content_provider( + [sent = false](std::string& chunk) mutable -> bool { + if (sent) + return false; + chunk = "payload"; + sent = true; + return true; + }, + "text/plain"); + // Application code may still touch the framing headers after the + // provider is installed; the wire must carry exactly one + // Transfer-Encoding and no Content-Length regardless. + res.set_header("Content-Length", "999"); + res.add_header("Transfer-Encoding", "chunked"); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /late-framing-headers HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + CHECK(response.find("Content-Length") == std::string::npos); + std::size_t transfer_encoding_count = 0; + for (std::size_t at = response.find("Transfer-Encoding"); + at != std::string::npos; + at = response.find("Transfer-Encoding", at + 1)) + ++transfer_encoding_count; + CHECK(transfer_encoding_count == 1); + CHECK(response.find("7\r\npayload\r\n") != std::string::npos); + + app.stop(); +} // chunked_response_canonicalizes_framing_headers + + +TEST_CASE("sync_chunked_response_does_not_block_the_worker_for_a_stalled_client") +{ + SimpleApp app; + + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + CROW_ROUTE(app, "/stalled-stream") + ([completion_observation](const crow::request&, crow::response& res) { + res.set_chunked_content_provider( + [](std::string& chunk) -> bool { + // Each chunk stays under the size cap; together they exceed any + // default socket buffer pair, so writes stall while the client + // refuses to read. + chunk.assign(8u * 1024u * 1024u, 'x'); + return true; + }, + "application/octet-stream"); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/ping") + ([] { + return "pong"; + }); + + // A generous write deadline: the default 5 s is within reach of this + // test's own waits on a slow runner, and a deadline abort would fire the + // completion early. + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).concurrency(1).timeout(30).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + // The stalled client requests the stream and never reads the body. + asio::io_context io_context; + asio::ip::tcp::socket stalled_client(io_context); + stalled_client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string stream_request = "GET /stalled-stream HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(stalled_client, asio::buffer(stream_request)); + + // Give the worker time to enter the stalled transfer. + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + + // The single worker must still serve another connection. + HttpClient ping_client(LOCALHOST_ADDRESS, 45451); + ping_client.send("GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + std::string ping_response; + const bool ping_answered = receive_until_closed_with_deadline(ping_client.socket(), ping_response, std::chrono::seconds(3)); + + // Sampled before the sockets close: peer closure aborts the stalled + // transfer, and that abort can win the race to the assertion below. + const auto completion_status_before_close = + completion_result.wait_for(std::chrono::milliseconds(0)); + + asio_error_code close_error; + stalled_client.close(close_error); + ping_client.socket().close(close_error); + + REQUIRE(ping_answered); + CHECK(ping_response.find("pong") != std::string::npos); + // The stalled transfer was still in flight while the ping was served: the + // worker was free during a genuinely stalled socket write, and the stream + // was not aborted by the chunk-size cap. + CHECK(completion_status_before_close == std::future_status::timeout); +} // sync_chunked_response_does_not_block_the_worker_for_a_stalled_client + + +TEST_CASE("chunked_response_times_out_writing_to_a_stalled_client") +{ + SimpleApp app; + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/stalled-write-deadline") + ([completion_observation](const crow::request&, crow::response& res) { + res.set_chunked_content_provider( + [sent = false](std::string& chunk) mutable -> bool { + if (sent) + return false; + chunk.assign(8u * 1024u * 1024u, 'x'); + sent = true; + return true; + }, + "application/octet-stream"); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).timeout(1).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + asio::io_context io_context; + asio::ip::tcp::socket stalled_client(io_context); + stalled_client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /stalled-write-deadline HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(stalled_client, asio::buffer(request)); + + // A client that stops reading trips the write deadline: the transfer is + // aborted and reported unclean instead of pinning the buffers forever. + const auto completion_status = completion_result.wait_for(std::chrono::seconds(5)); + REQUIRE(completion_status == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(completion_observation->calls() == 1); + + asio_error_code close_error; + stalled_client.close(close_error); +} // chunked_response_times_out_writing_to_a_stalled_client + + +TEST_CASE("close_marked_transfers_keep_the_write_deadline") +{ + SimpleApp app; + auto request_close_completion = std::make_shared(); + auto request_close_result = request_close_completion->first_result(); + auto response_close_completion = std::make_shared(); + auto response_close_result = response_close_completion->first_result(); + + const auto endless_stream = [](const std::shared_ptr& completion, crow::response& res) { + res.set_chunked_content_provider( + [](std::string& chunk) -> bool { + chunk.assign(8u * 1024u * 1024u, 'x'); + return true; + }, + "application/octet-stream"); + res.set_chunked_completion_handler([completion](bool clean) { + completion->record(clean); + }); + res.end(); + }; + CROW_ROUTE(app, "/stalled-close-request") + ([endless_stream, request_close_completion](const crow::request&, crow::response& res) { + endless_stream(request_close_completion, res); + }); + CROW_ROUTE(app, "/stalled-close-response") + ([endless_stream, response_close_completion](const crow::request&, crow::response& res) { + res.set_header("Connection", "close"); + endless_stream(response_close_completion, res); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).timeout(1).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + const auto stall = [](const std::string& request_text, std::future& result) { + asio::io_context io_context; + asio::ip::tcp::socket stalled_client(io_context); + stalled_client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + asio::write(stalled_client, asio::buffer(request_text)); + // The client never reads: the write deadline must abort the transfer + // even though the connection is already marked to close. + const auto completion_status = result.wait_for(std::chrono::seconds(5)); + asio_error_code close_error; + stalled_client.close(close_error); + REQUIRE(completion_status == std::future_status::ready); + return result.get(); + }; + + // A request-side "Connection: close" marks the connection before the + // in-flight transfer submits its first write. + CHECK(stall("GET /stalled-close-request HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n", request_close_result) == false); + // An application-supplied "Connection: close" marks it during finalization. + CHECK(stall("GET /stalled-close-response HTTP/1.1\r\nHost: localhost\r\n\r\n", response_close_result) == false); + server_shutdown.shutdown(); + + CHECK(request_close_completion->calls() == 1); + CHECK(response_close_completion->calls() == 1); +} // close_marked_transfers_keep_the_write_deadline + + +TEST_CASE("async_chunked_response_aborts_an_idle_provider_when_configured") +{ + SimpleApp app; + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto provider_calls = std::make_shared>(0); + + CROW_ROUTE(app, "/idle-provider") + ([completion_observation, provider_calls](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t) { + provider_calls->fetch_add(1); + }); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).stream_idle_timeout(1).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /idle-provider HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + REQUIRE(completion_status == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(completion_observation->calls() == 1); + CHECK(provider_calls->load() == 1); + REQUIRE(connection_closed); + CHECK(response.find("0\r\n\r\n") == std::string::npos); + + asio_error_code close_error; + client.socket().close(close_error); +} // async_chunked_response_aborts_an_idle_provider_when_configured + + +TEST_CASE("async_chunked_response_aborts_oversized_chunks") +{ + SimpleApp app; + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/oversized-chunk") + ([completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [](crow::response::async_chunk_completion_t complete) { + complete(crow::chunk_result::done, std::string(2048, 'x')); + }); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).max_stream_chunk_size(1024).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /oversized-chunk HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + REQUIRE(completion_status == std::future_status::ready); + CHECK(completion_result.get() == false); + REQUIRE(connection_closed); + CHECK(response.find("0\r\n\r\n") == std::string::npos); + + asio_error_code close_error; + client.socket().close(close_error); +} // async_chunked_response_aborts_oversized_chunks + + +TEST_CASE("async_chunked_response_move_assignment_releases_destination_provider") { + response destination; + auto destination_marker = std::make_shared(1); + std::weak_ptr destination_marker_observer = destination_marker; + destination.set_async_chunked_content_provider([destination_marker](response::async_chunk_completion_t complete) { + complete(response::chunk_result::done, "old"); + }); + destination_marker.reset(); + + response source; + auto source_marker = std::make_shared(1); + std::weak_ptr source_marker_observer = source_marker; + source.set_header("X-Source", "moved"); + source.set_async_chunked_content_provider([source_marker](response::async_chunk_completion_t complete) { + complete(response::chunk_result::done, "new"); + }); + source_marker.reset(); + + destination = std::move(source); + + CHECK(destination_marker_observer.expired()); + CHECK(!source_marker_observer.expired()); + CHECK(destination.is_chunked_type()); + CHECK(destination.get_header_value("Transfer-Encoding") == "chunked"); + CHECK(destination.get_header_value("X-Source") == "moved"); + + destination.clear(); + + CHECK(source_marker_observer.expired()); + CHECK(!destination.is_chunked_type()); +} // async_chunked_response_move_assignment_releases_destination_provider + + +TEST_CASE("response_move_assignment_keeps_the_completion_handler_of_a_handlerless_source") +{ + response destination; + auto marker = std::make_shared(1); + std::weak_ptr marker_observer = marker; + auto observation = std::make_shared(); + destination.set_async_chunked_content_provider([](response::async_chunk_completion_t) {}); + destination.set_chunked_completion_handler([marker, observation](bool clean) { + observation->record(clean); + }); + marker.reset(); + + destination = response(500); + + CHECK(destination.code == 500); + CHECK(!destination.is_chunked_type()); + CHECK(destination.get_header_value("Transfer-Encoding").empty()); + CHECK(observation->calls() == 0); + CHECK(!marker_observer.expired()); + + destination.clear(); + + CHECK(marker_observer.expired()); +} // response_move_assignment_keeps_the_completion_handler_of_a_handlerless_source + + +TEST_CASE("response_move_assignment_takes_the_completion_handler_of_a_source_that_carries_one") +{ + response destination; + auto destination_marker = std::make_shared(1); + std::weak_ptr destination_marker_observer = destination_marker; + auto destination_observation = std::make_shared(); + destination.set_async_chunked_content_provider([](response::async_chunk_completion_t) {}); + destination.set_chunked_completion_handler([destination_marker, destination_observation](bool clean) { + destination_observation->record(clean); + }); + destination_marker.reset(); + + response source; + auto source_marker = std::make_shared(1); + std::weak_ptr source_marker_observer = source_marker; + auto source_observation = std::make_shared(); + source.set_async_chunked_content_provider([](response::async_chunk_completion_t) {}); + source.set_chunked_completion_handler([source_marker, source_observation](bool clean) { + source_observation->record(clean); + }); + source_marker.reset(); + + destination = std::move(source); + + CHECK(destination_marker_observer.expired()); + CHECK(destination_observation->calls() == 0); + CHECK(!source_marker_observer.expired()); + CHECK(source_observation->calls() == 0); + + destination.clear(); + + CHECK(source_marker_observer.expired()); +} // response_move_assignment_takes_the_completion_handler_of_a_source_that_carries_one + + +TEST_CASE("async_chunk_transfer_registry_ignores_ordinary_connections_and_unregisters_completed_transfers") +{ + crow::detail::connection_lifecycle_registry registry; + auto ordinary_connection = std::make_shared(); + auto active_transfer = std::make_shared(); + + CHECK(registry.track(active_transfer)); + registry.untrack(active_transfer.get()); + registry.shutdown_all(); + + CHECK(active_transfer->shutdown_calls() == 0); + + CHECK_FALSE(registry.track(active_transfer)); + + CHECK(active_transfer->shutdown_calls() == 0); + CHECK(ordinary_connection->shutdown_calls() == 0); +} // async_chunk_transfer_registry_ignores_ordinary_connections_and_unregisters_completed_transfers + + +TEST_CASE("async_chunk_transfer_registry_shuts_down_tracked_connections_once") +{ + crow::detail::connection_lifecycle_registry registry; + auto active_transfer = std::make_shared(); + + CHECK(registry.track(active_transfer)); + registry.shutdown_all(); + registry.shutdown_all(); + + CHECK(active_transfer->shutdown_calls() == 1); + CHECK_FALSE(registry.track(active_transfer)); +} // async_chunk_transfer_registry_shuts_down_tracked_connections_once + + +TEST_CASE("async_chunk_transfer_registry_retains_connections_until_untracked") +{ + crow::detail::connection_lifecycle_registry registry; + auto connection = std::make_shared(); + auto* connection_key = connection.get(); + std::weak_ptr connection_observer = connection; + + REQUIRE(registry.track(connection)); + connection.reset(); + registry.shutdown_all(); + + REQUIRE_FALSE(connection_observer.expired()); + CHECK(connection_observer.lock()->shutdown_calls() == 1); + + registry.untrack(connection_key); + CHECK(connection_observer.expired()); +} // async_chunk_transfer_registry_retains_connections_until_untracked + + +TEST_CASE("async_chunk_transfer_registry_serializes_untrack_with_shutdown") +{ + crow::detail::connection_lifecycle_registry registry; + auto active_transfer = std::make_shared(); + REQUIRE(registry.track(active_transfer)); + + auto shutdown = std::async(std::launch::async, [®istry] { + registry.shutdown_all(); + }); + registry.untrack(active_transfer.get()); + shutdown.get(); + + CHECK(active_transfer->shutdown_calls() <= 1); + CHECK_FALSE(registry.track(active_transfer)); +} // async_chunk_transfer_registry_serializes_untrack_with_shutdown + + +TEST_CASE("empty_async_chunk_provider_closes_without_terminator_and_completes_once_unclean") +{ + SimpleApp app; + + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/empty-async-chunk-provider") + ([completion_observation](const crow::request&, crow::response& res) { + crow::response::async_chunk_provider_t empty_provider; + res.set_async_chunked_content_provider(std::move(empty_provider), "text/plain"); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /empty-async-chunk-provider HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + CHECK(response.substr(header_end + 4).find("0\r\n\r\n") == std::string::npos); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); +} // empty_async_chunk_provider_closes_without_terminator_and_completes_once_unclean + + +TEST_CASE("http_1_0_rejects_synchronous_and_asynchronous_chunk_providers") +{ + SimpleApp app; + + auto synchronous_provider_calls = std::make_shared>(0); + auto asynchronous_provider_calls = std::make_shared>(0); + auto synchronous_completion = std::make_shared(); + auto asynchronous_completion = std::make_shared(); + auto synchronous_result = synchronous_completion->first_result(); + auto asynchronous_result = asynchronous_completion->first_result(); + + CROW_ROUTE(app, "/http-1-0-sync-chunks") + ([synchronous_provider_calls, synchronous_completion](const crow::request&, crow::response& res) { + res.set_chunked_content_provider( + [synchronous_provider_calls](std::string& chunk) { + synchronous_provider_calls->fetch_add(1); + chunk = "sync"; + return crow::chunk_result::done; + }, + "text/plain"); + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.set_chunked_completion_handler( + [synchronous_completion](bool clean) { + synchronous_completion->record(clean); + }); + res.end(); + }); + + CROW_ROUTE(app, "/http-1-0-async-chunks") + ([asynchronous_provider_calls, asynchronous_completion](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [asynchronous_provider_calls](crow::response::async_chunk_completion_t complete) { + asynchronous_provider_calls->fetch_add(1); + complete(crow::chunk_result::done, "async"); + }, + "text/plain"); + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.set_chunked_completion_handler( + [asynchronous_completion](bool clean) { + asynchronous_completion->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient synchronous_client(LOCALHOST_ADDRESS, 45451); + synchronous_client.send("GET /http-1-0-sync-chunks HTTP/1.0\r\n\r\n"); + std::string synchronous_response; + const bool synchronous_connection_closed = receive_until_closed_with_deadline( + synchronous_client.socket(), synchronous_response, std::chrono::seconds(5)); + + HttpClient asynchronous_client(LOCALHOST_ADDRESS, 45451); + asynchronous_client.send("GET /http-1-0-async-chunks HTTP/1.0\r\n\r\n"); + std::string asynchronous_response; + const bool asynchronous_connection_closed = receive_until_closed_with_deadline( + asynchronous_client.socket(), asynchronous_response, std::chrono::seconds(5)); + + const auto synchronous_completion_status = synchronous_result.wait_for(std::chrono::seconds(1)); + const bool synchronous_clean = synchronous_completion_status == std::future_status::ready ? synchronous_result.get() : true; + const auto asynchronous_completion_status = asynchronous_result.wait_for(std::chrono::seconds(1)); + const bool asynchronous_clean = asynchronous_completion_status == std::future_status::ready ? asynchronous_result.get() : true; + + asio_error_code close_error; + synchronous_client.socket().close(close_error); + asynchronous_client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(synchronous_connection_closed); + REQUIRE(asynchronous_connection_closed); + CHECK(has_complete_http_response(synchronous_response)); + CHECK(has_complete_http_response(asynchronous_response)); + CHECK(synchronous_response.find("HTTP/1.1 505 HTTP Version Not Supported") != std::string::npos); + CHECK(asynchronous_response.find("HTTP/1.1 505 HTTP Version Not Supported") != std::string::npos); + CHECK(synchronous_response.find("Transfer-Encoding") == std::string::npos); + CHECK(asynchronous_response.find("Transfer-Encoding") == std::string::npos); + CHECK(synchronous_response.find("Content-Encoding") == std::string::npos); + CHECK(asynchronous_response.find("Content-Encoding") == std::string::npos); + CHECK(synchronous_response.find("Trailer") == std::string::npos); + CHECK(asynchronous_response.find("Trailer") == std::string::npos); + CHECK(synchronous_response.find("Content-Length") != std::string::npos); + CHECK(asynchronous_response.find("Content-Length") != std::string::npos); + CHECK(synchronous_provider_calls->load() == 0); + CHECK(asynchronous_provider_calls->load() == 0); + REQUIRE(synchronous_completion_status == std::future_status::ready); + REQUIRE(asynchronous_completion_status == std::future_status::ready); + CHECK(synchronous_clean == false); + CHECK(asynchronous_clean == false); + CHECK(synchronous_completion->calls() == 1); + CHECK(asynchronous_completion->calls() == 1); +} // http_1_0_rejects_synchronous_and_asynchronous_chunk_providers + + +TEST_CASE("http_1_0_rejects_head_chunk_providers_without_sending_the_error_body") +{ + SimpleApp app; + + auto synchronous_provider_calls = std::make_shared>(0); + auto asynchronous_provider_calls = std::make_shared>(0); + auto synchronous_completion = std::make_shared(); + auto asynchronous_completion = std::make_shared(); + auto synchronous_result = synchronous_completion->first_result(); + auto asynchronous_result = asynchronous_completion->first_result(); + + CROW_ROUTE(app, "/http-1-0-head-sync-chunks") + .methods("GET"_method, + "HEAD"_method)([synchronous_provider_calls, synchronous_completion](const crow::request&, + crow::response& res) { + res.set_chunked_content_provider([synchronous_provider_calls](std::string& chunk) { + synchronous_provider_calls->fetch_add(1); + chunk = "sync"; + return crow::chunk_result::done; + }); + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.set_chunked_completion_handler( + [synchronous_completion](bool clean) { + synchronous_completion->record(clean); + }); + res.end(); + }); + + CROW_ROUTE(app, "/http-1-0-head-async-chunks") + .methods("GET"_method, + "HEAD"_method)([asynchronous_provider_calls, asynchronous_completion](const crow::request&, + crow::response& res) { + res.set_async_chunked_content_provider( + [asynchronous_provider_calls](crow::response::async_chunk_completion_t complete) { + asynchronous_provider_calls->fetch_add(1); + complete(crow::chunk_result::done, "async"); + }); + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.set_chunked_completion_handler( + [asynchronous_completion](bool clean) { + asynchronous_completion->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient synchronous_client(LOCALHOST_ADDRESS, 45451); + synchronous_client.send("HEAD /http-1-0-head-sync-chunks HTTP/1.0\r\n\r\n"); + std::string synchronous_response; + const bool synchronous_connection_closed = receive_until_closed_with_deadline( + synchronous_client.socket(), synchronous_response, std::chrono::seconds(5)); + + HttpClient asynchronous_client(LOCALHOST_ADDRESS, 45451); + asynchronous_client.send("HEAD /http-1-0-head-async-chunks HTTP/1.0\r\n\r\n"); + std::string asynchronous_response; + const bool asynchronous_connection_closed = receive_until_closed_with_deadline( + asynchronous_client.socket(), asynchronous_response, std::chrono::seconds(5)); + + const auto synchronous_completion_status = synchronous_result.wait_for(std::chrono::seconds(1)); + const bool synchronous_clean = synchronous_completion_status == std::future_status::ready ? synchronous_result.get() : true; + const auto asynchronous_completion_status = asynchronous_result.wait_for(std::chrono::seconds(1)); + const bool asynchronous_clean = asynchronous_completion_status == std::future_status::ready ? asynchronous_result.get() : true; + + asio_error_code close_error; + synchronous_client.socket().close(close_error); + asynchronous_client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(synchronous_connection_closed); + REQUIRE(asynchronous_connection_closed); + const auto synchronous_header_end = synchronous_response.find("\r\n\r\n"); + const auto asynchronous_header_end = asynchronous_response.find("\r\n\r\n"); + REQUIRE(synchronous_header_end != std::string::npos); + REQUIRE(asynchronous_header_end != std::string::npos); + CHECK(synchronous_response.find("HTTP/1.1 505 HTTP Version Not Supported") != std::string::npos); + CHECK(asynchronous_response.find("HTTP/1.1 505 HTTP Version Not Supported") != std::string::npos); + CHECK(synchronous_response.find("Content-Length: 32") != std::string::npos); + CHECK(asynchronous_response.find("Content-Length: 32") != std::string::npos); + CHECK(synchronous_response.find("Connection: close") != std::string::npos); + CHECK(asynchronous_response.find("Connection: close") != std::string::npos); + CHECK(synchronous_response.find("Transfer-Encoding") == std::string::npos); + CHECK(asynchronous_response.find("Transfer-Encoding") == std::string::npos); + CHECK(synchronous_response.find("Content-Encoding") == std::string::npos); + CHECK(asynchronous_response.find("Content-Encoding") == std::string::npos); + CHECK(synchronous_response.find("Trailer") == std::string::npos); + CHECK(asynchronous_response.find("Trailer") == std::string::npos); + CHECK(synchronous_response.substr(synchronous_header_end + 4).empty()); + CHECK(asynchronous_response.substr(asynchronous_header_end + 4).empty()); + CHECK(synchronous_provider_calls->load() == 0); + CHECK(asynchronous_provider_calls->load() == 0); + REQUIRE(synchronous_completion_status == std::future_status::ready); + REQUIRE(asynchronous_completion_status == std::future_status::ready); + CHECK(synchronous_clean == false); + CHECK(asynchronous_clean == false); + CHECK(synchronous_completion->calls() == 1); + CHECK(asynchronous_completion->calls() == 1); +} // http_1_0_rejects_head_chunk_providers_without_sending_the_error_body + + +TEST_CASE("clear_after_chunk_provider_restores_content_length_and_keep_alive_boundaries") +{ + SimpleApp app; + + auto synchronous_provider_calls = std::make_shared>(0); + auto asynchronous_provider_calls = std::make_shared>(0); + + CROW_ROUTE(app, "/clear-sync-chunks") + ([synchronous_provider_calls](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([synchronous_provider_calls](std::string& chunk) { + synchronous_provider_calls->fetch_add(1); + chunk = "unused"; + return crow::chunk_result::done; + }); + res.clear(); + res.end("sync-body"); + }); + + CROW_ROUTE(app, "/clear-async-chunks") + ([asynchronous_provider_calls](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [asynchronous_provider_calls](crow::response::async_chunk_completion_t complete) { + asynchronous_provider_calls->fetch_add(1); + complete(crow::chunk_result::done, "unused"); + }); + res.clear(); + res.end("async-body"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /clear-sync-chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string synchronous_response; + const bool synchronous_complete = receive_with_deadline(client.socket(), + synchronous_response, + std::chrono::seconds(5), + has_complete_http_response); + + std::string asynchronous_response; + bool asynchronous_connection_closed = false; + if (synchronous_complete) + { + client.send( + "GET /clear-async-chunks HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + asynchronous_connection_closed = receive_until_closed_with_deadline( + client.socket(), asynchronous_response, std::chrono::seconds(5)); + } + + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(synchronous_complete); + REQUIRE(asynchronous_connection_closed); + const auto synchronous_header_end = synchronous_response.find("\r\n\r\n"); + const auto asynchronous_header_end = asynchronous_response.find("\r\n\r\n"); + REQUIRE(synchronous_header_end != std::string::npos); + REQUIRE(asynchronous_header_end != std::string::npos); + CHECK(synchronous_response.find("Content-Length: 9") != std::string::npos); + CHECK(asynchronous_response.find("Content-Length: 10") != std::string::npos); + CHECK(synchronous_response.find("Transfer-Encoding") == std::string::npos); + CHECK(asynchronous_response.find("Transfer-Encoding") == std::string::npos); + CHECK(synchronous_response.substr(synchronous_header_end + 4) == "sync-body"); + CHECK(asynchronous_response.substr(asynchronous_header_end + 4) == "async-body"); + CHECK(synchronous_provider_calls->load() == 0); + CHECK(asynchronous_provider_calls->load() == 0); +} // clear_after_chunk_provider_restores_content_length_and_keep_alive_boundaries + + +TEST_CASE("throwing_async_chunk_provider_closes_without_terminator_and_completes_once_unclean") +{ + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/throwing-async-chunk-provider") + ([provider_calls, completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t) { + provider_calls->fetch_add(1); + throw std::runtime_error("asynchronous provider failure"); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /throwing-async-chunk-provider HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).find("0\r\n\r\n") == std::string::npos); + CHECK(provider_calls->load() == 1); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); +} // throwing_async_chunk_provider_closes_without_terminator_and_completes_once_unclean + + +TEST_CASE("async_chunked_response_head_suppresses_provider_and_completes_once_clean") { + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/head-async-chunks") + .methods("GET"_method, + "HEAD"_method)([provider_calls, completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t complete) { + provider_calls->fetch_add(1); + complete(crow::chunk_result::done, "body"); + }, + "text/plain"); + res.set_header("X-Streaming-Mode", "async"); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { completion_observation->record(clean); }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { app.stop(); }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "HEAD /head-async-chunks HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string received; + const bool peer_closed = receive_until_closed_with_deadline(client, received, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(5)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : false; + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(peer_closed); + const auto header_end = received.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(received.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(received.find("Content-Length") == std::string::npos); + CHECK(received.find("Content-Type: text/plain") != std::string::npos); + CHECK(received.find("X-Streaming-Mode: async") != std::string::npos); + CHECK(received.substr(header_end + 4).empty()); + CHECK(provider_calls->load() == 0); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == true); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_head_suppresses_provider_and_completes_once_clean + + +TEST_CASE("async_chunked_response_clean_completion_restores_keep_alive_reading") { + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto first_route_calls = std::make_shared>(0); + auto second_route_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto worker_tasks = std::make_shared>>(); + auto worker_tasks_mutex = std::make_shared(); + + CROW_ROUTE(app, "/first-async-response") + ([provider_calls, first_route_calls, completion_observation, worker_tasks, worker_tasks_mutex]( + const crow::request&, crow::response& res) { + first_route_calls->fetch_add(1); + res.set_async_chunked_content_provider( + [provider_calls, worker_tasks, worker_tasks_mutex](crow::response::async_chunk_completion_t complete) { + provider_calls->fetch_add(1); + std::lock_guard lock(*worker_tasks_mutex); + worker_tasks->emplace_back(std::async(std::launch::async, [complete = std::move(complete)]() mutable { + complete(crow::chunk_result::done, "first"); + })); + }, + "text/plain"); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { completion_observation->record(clean); }); + res.end(); + }); + + CROW_ROUTE(app, "/second-regular-response") + ([second_route_calls] { + second_route_calls->fetch_add(1); + return "second"; + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { app.stop(); }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string first_request = "GET /first-async-response HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(first_request)); + + const auto has_chunk_terminator = [](const std::string& response) { + return response.size() >= 5 && response.compare(response.size() - 5, 5, "0\r\n\r\n") == 0; + }; + std::string first_response; + const bool first_response_complete + = receive_with_deadline(client, first_response, std::chrono::seconds(5), has_chunk_terminator); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(5)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : false; + + const std::string second_request + = "GET /second-regular-response HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(second_request)); + std::string second_response; + const bool second_response_complete + = receive_with_deadline(client, second_response, std::chrono::seconds(5), has_complete_http_response); + + std::vector> tasks; + { + std::lock_guard lock(*worker_tasks_mutex); + tasks.swap(*worker_tasks); + } + for (auto& task : tasks) + task.get(); + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(first_response_complete); + const auto first_header_end = first_response.find("\r\n\r\n"); + REQUIRE(first_header_end != std::string::npos); + CHECK(first_response.find("200 OK") != std::string::npos); + CHECK(first_response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(first_response.find("Content-Length") == std::string::npos); + CHECK(first_response.substr(first_header_end + 4) == "5\r\nfirst\r\n0\r\n\r\n"); + + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == true); + CHECK(completion_observation->calls() == 1); + CHECK(provider_calls->load() == 1); + CHECK(first_route_calls->load() == 1); + + REQUIRE(second_response_complete); + const auto second_header_end = second_response.find("\r\n\r\n"); + REQUIRE(second_header_end != std::string::npos); + CHECK(second_response.find("200 OK") != std::string::npos); + CHECK(second_response.find("Transfer-Encoding: chunked") == std::string::npos); + CHECK(second_response.substr(second_header_end + 4) == "second"); + CHECK(second_route_calls->load() == 1); +} // async_chunked_response_clean_completion_restores_keep_alive_reading + + +TEST_CASE("deferred_chunked_response_stops_parsing_at_its_request_boundary") +{ + SimpleApp app; + + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto second_route_calls = std::make_shared>(0); + + CROW_ROUTE(app, "/deferred-chunked-boundary") + ([deferred_end_promise](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [](crow::response::async_chunk_completion_t complete) { + complete(crow::chunk_result::done, "first"); + }); + deferred_end_promise->set_value([&res] { + res.end(); + }); + }); + + CROW_ROUTE(app, "/after-deferred-chunked-boundary") + ([second_route_calls](const crow::request&, crow::response& res) { + second_route_calls->fetch_add(1); + res.end("second"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests = "GET /deferred-chunked-boundary HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-deferred-chunked-boundary HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(second_route_calls->load() == 0); + deferred_end.get()(); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(second_route_calls->load() == 0); + CHECK(response.find("5\r\nfirst\r\n0\r\n\r\n") != std::string::npos); + CHECK(response.find("second") == std::string::npos); +} // deferred_chunked_response_stops_parsing_at_its_request_boundary + + +TEST_CASE("deferred_head_chunked_response_closes_after_pipelined_input") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto provider_calls = std::make_shared>(0); + auto second_route_calls = std::make_shared>(0); + + CROW_ROUTE(app, "/deferred-head-boundary") + ([deferred_end_promise, provider_calls](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t) { + provider_calls->fetch_add(1); + }); + deferred_end_promise->set_value([&res] { + res.end(); + }); + }); + CROW_ROUTE(app, "/after-deferred-head-boundary") + ([second_route_calls](const crow::request&, crow::response& res) { + second_route_calls->fetch_add(1); + res.end("second"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests = "HEAD /deferred-head-boundary HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-deferred-head-boundary HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(second_route_calls->load() == 0); + deferred_end.get()(); + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(provider_calls->load() == 0); + CHECK(second_route_calls->load() == 0); + const auto first_header_end = response.find("\r\n\r\n"); + REQUIRE(first_header_end != std::string::npos); + const auto first_headers = response.substr(0, first_header_end + 4); + CHECK(first_headers.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(first_headers.find("Content-Length:") == std::string::npos); + CHECK(response.size() == first_header_end + 4); +} // deferred_head_chunked_response_closes_after_pipelined_input + + +TEST_CASE("unmatched_head_does_not_emit_a_body") +{ + SimpleApp app; + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "HEAD /missing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + const auto first_header_end = response.find("\r\n\r\n"); + REQUIRE(first_header_end != std::string::npos); + CHECK(response.find("Content-Length: 15\r\n") != std::string::npos); + CHECK(response.size() == first_header_end + 4); +} // unmatched_head_does_not_emit_a_body + + +TEST_CASE("static_head_preserves_representation_length") +{ + SimpleApp app; + struct stat file_status + {}; + REQUIRE(stat("tests/img/cat.jpg", &file_status) == 0); + + CROW_ROUTE(app, "/static-head") + ([](const crow::request&, crow::response& res) { + res.set_static_file_info("tests/img/cat.jpg"); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("HEAD /static-head HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + std::string response; + const bool closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.find("Content-Length: " + std::to_string(file_status.st_size)) != std::string::npos); + CHECK(response.size() == header_end + 4); +} // static_head_preserves_representation_length + + +TEST_CASE("unsupported_informational_status_uses_normalized_framing") +{ + SimpleApp app; + auto interim_provider_calls = std::make_shared>(0); + auto interim_completion = std::make_shared(); + auto interim_result = interim_completion->first_result(); + CROW_ROUTE(app, "/unsupported-status") + ([](const crow::request&, crow::response& res) { + res.code = 199; + // Discarded together with the body: the synthesized 500 carries no + // coding and Crow sends no trailer section. + res.body = "interim details"; + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.end(); + }); + CROW_ROUTE(app, "/interim-status") + ([interim_provider_calls, interim_completion](const crow::request&, crow::response& res) { + res.code = 101; + res.set_chunked_content_provider([interim_provider_calls](std::string& chunk) { + interim_provider_calls->fetch_add(1); + chunk = "forbidden"; + return false; + }); + res.set_chunked_completion_handler( + [interim_completion](bool clean) { + interim_completion->record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/after-unsupported-status") + ([] { + return "second"; + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests = "GET /unsupported-status HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /interim-status HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-unsupported-status HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + std::string response; + REQUIRE(receive_until_closed_with_deadline(client, response, std::chrono::seconds(5))); + server_shutdown.shutdown(); + + CHECK(response.find("HTTP/1.1 500 Internal Server Error\r\n") == 0); + CHECK(response.find("Content-Length: 27\r\n") != std::string::npos); + // A handler-returned interim status is normalized to a final 500 as well, + // its provider is suppressed, and the completion handler still reports. + const auto interim_response = response.find("HTTP/1.1 500 Internal Server Error\r\n", 1); + REQUIRE(interim_response != std::string::npos); + CHECK(response.find("Transfer-Encoding:") == std::string::npos); + CHECK(response.find("Content-Encoding:") == std::string::npos); + CHECK(response.find("Trailer:") == std::string::npos); + CHECK(interim_provider_calls->load() == 0); + REQUIRE(interim_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(interim_result.get() == true); + CHECK(interim_completion->calls() == 1); + const auto second_response = response.find("HTTP/1.1 200 OK\r\n"); + REQUIRE(second_response != std::string::npos); + CHECK(second_response > interim_response); +} // unsupported_informational_status_uses_normalized_framing + + +TEST_CASE("ordinary_deferred_response_closes_after_pipelined_input") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto second_route_calls = std::make_shared>(0); + + CROW_ROUTE(app, "/ordinary-deferred-first") + ([deferred_end_promise](const crow::request&, crow::response& res) { + deferred_end_promise->set_value([&res] { + res.end("first"); + }); + }); + CROW_ROUTE(app, "/ordinary-deferred-second") + ([second_route_calls] { + second_route_calls->fetch_add(1); + return "second"; + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests = "GET /ordinary-deferred-first HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /ordinary-deferred-second HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(second_route_calls->load() == 0); + deferred_end.get()(); + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(second_route_calls->load() == 0); + CHECK(response.find("first") != std::string::npos); + CHECK(response.find("second") == std::string::npos); +} // ordinary_deferred_response_closes_after_pipelined_input + + +TEST_CASE("server_stop_cleans_up_queued_deferred_finalization") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto blocker_started_promise = std::make_shared>(); + auto blocker_started = blocker_started_promise->get_future(); + auto blocker_release_promise = std::make_shared>(); + auto blocker_release = blocker_release_promise->get_future().share(); + auto provider_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto provider_marker_holder = std::make_shared>(std::make_shared(1)); + std::weak_ptr provider_marker_observer = *provider_marker_holder; + + CROW_ROUTE(app, "/queued-deferred-finalization") + ([deferred_end_promise, + blocker_started_promise, + blocker_release, + provider_calls, + completion_observation, + provider_marker_holder](const crow::request& req, crow::response& res) { + auto provider_marker = std::move(*provider_marker_holder); + res.set_async_chunked_content_provider( + [provider_calls, provider_marker](crow::response::async_chunk_completion_t) { + provider_calls->fetch_add(1); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + asio::post(*req.io_context, [blocker_started_promise, blocker_release] { + blocker_started_promise->set_value(); + blocker_release.wait(); + }); + deferred_end_promise->set_value([&res] { + res.end(); + }); + }); + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /queued-deferred-finalization HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + REQUIRE(blocker_started.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + deferred_end.get()(); + app.stop(); + blocker_release_promise->set_value(); + + REQUIRE(server_task.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + CHECK(completion_status == std::future_status::ready); + if (completion_status == std::future_status::ready) + { + CHECK(completion_result.get() == false); + } + CHECK(completion_observation->calls() == 1); + CHECK(provider_calls->load() == 0); + CHECK(provider_marker_observer.expired()); + asio_error_code close_error; + client.socket().close(close_error); +} // server_stop_cleans_up_queued_deferred_finalization + + +TEST_CASE("deferred_end_after_worker_shutdown_is_safe") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto provider_marker_holder = std::make_shared>(std::make_shared(1)); + std::weak_ptr provider_marker_observer = *provider_marker_holder; + PausingSocketContext socket_context; + auto connection_destroyed = socket_context.started_connection_destroyed_future(); + + CROW_ROUTE(app, "/late-deferred-end") + ([deferred_end_promise, completion_observation, provider_marker_holder](const crow::request&, crow::response& res) { + auto provider_marker = std::move(*provider_marker_holder); + res.set_async_chunked_content_provider( + [provider_marker](crow::response::async_chunk_completion_t) { + static_cast(provider_marker); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + deferred_end_promise->set_value([&res] { + res.end(); + }); + }); + + app.validate(); + std::tuple<> middlewares; + using DeferredServer = crow::Server; + DeferredServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /late-deferred-end HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + server.stop(); + REQUIRE(server_task.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(provider_marker_observer.expired()); + CHECK(connection_destroyed.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout); + + deferred_end.get()(); + CHECK(completion_observation->calls() == 1); + CHECK(connection_destroyed.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + + asio_error_code close_error; + client.socket().close(close_error); +} // deferred_end_after_worker_shutdown_is_safe + + +TEST_CASE("deferred_end_with_body_after_worker_shutdown_is_safe") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto provider_marker_holder = std::make_shared>(std::make_shared(1)); + std::weak_ptr provider_marker_observer = *provider_marker_holder; + PausingSocketContext socket_context; + auto connection_destroyed = socket_context.started_connection_destroyed_future(); + + CROW_ROUTE(app, "/late-deferred-end-with-body") + ([deferred_end_promise, completion_observation, provider_marker_holder](const crow::request&, crow::response& res) { + auto provider_marker = std::move(*provider_marker_holder); + res.set_async_chunked_content_provider( + [provider_marker](crow::response::async_chunk_completion_t) { + static_cast(provider_marker); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + deferred_end_promise->set_value([&res] { + res.end("late"); + }); + }); + + app.validate(); + std::tuple<> middlewares; + using DeferredServer = crow::Server; + DeferredServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /late-deferred-end-with-body HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + server.stop(); + REQUIRE(server_task.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(provider_marker_observer.expired()); + CHECK(connection_destroyed.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout); + + deferred_end.get()(); + CHECK(completion_observation->calls() == 1); + CHECK(connection_destroyed.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + + asio_error_code close_error; + client.socket().close(close_error); +} // deferred_end_with_body_after_worker_shutdown_is_safe + + +TEST_CASE("connection_destruction_reports_unstarted_deferred_completion") +{ + SimpleApp app; + asio::io_context io_context; + crow::detail::task_timer task_timer(io_context); + std::function date_str_getter = [] { + return std::string("Tue, 01 Jan 2030 00:00:00 GMT"); + }; + std::tuple<> middlewares; + std::atomic queue_length{0}; + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + using DirectConnection = crow::Connection; + { + auto connection = std::make_shared(io_context, + &app, + "Crow/Test", + &middlewares, + date_str_getter, + task_timer, + nullptr, + queue_length); + auto& res = crow::connection_test_access::res(*connection); + res.set_async_chunked_content_provider( + [](crow::response::async_chunk_completion_t) {}); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + } + + const auto completion_status = completion_result.wait_for(std::chrono::milliseconds(100)); + CHECK(completion_status == std::future_status::ready); + if (completion_status == std::future_status::ready) + { + CHECK(completion_result.get() == false); + } + CHECK(completion_observation->calls() == 1); + CHECK(queue_length.load() == 0); +} // connection_destruction_reports_unstarted_deferred_completion + + +TEST_CASE("bodyless_statuses_do_not_invoke_chunk_providers") +{ + SimpleApp app; + auto sync_204_calls = std::make_shared>(0); + auto async_204_calls = std::make_shared>(0); + auto sync_304_calls = std::make_shared>(0); + auto async_304_calls = std::make_shared>(0); + auto sync_205_calls = std::make_shared>(0); + auto async_205_calls = std::make_shared>(0); + auto sync_204_completion = std::make_shared(); + auto async_204_completion = std::make_shared(); + auto sync_304_completion = std::make_shared(); + auto async_304_completion = std::make_shared(); + auto sync_205_completion = std::make_shared(); + auto async_205_completion = std::make_shared(); + auto sync_204_result = sync_204_completion->first_result(); + auto async_204_result = async_204_completion->first_result(); + auto sync_304_result = sync_304_completion->first_result(); + auto async_304_result = async_304_completion->first_result(); + auto sync_205_result = sync_205_completion->first_result(); + auto async_205_result = async_205_completion->first_result(); + + CROW_ROUTE(app, "/sync-204") + ([sync_204_calls, sync_204_completion](const crow::request&, crow::response& res) { + res.code = 204; + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.set_chunked_content_provider([sync_204_calls](std::string& chunk) { + sync_204_calls->fetch_add(1); + chunk = "forbidden"; + return false; + }); + res.set_chunked_completion_handler( + [sync_204_completion](bool clean) { + sync_204_completion->record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/async-204") + ([async_204_calls, async_204_completion](const crow::request&, crow::response& res) { + res.code = 204; + res.set_async_chunked_content_provider( + [async_204_calls](crow::response::async_chunk_completion_t complete) { + async_204_calls->fetch_add(1); + complete(crow::chunk_result::done, "forbidden"); + }); + res.set_chunked_completion_handler( + [async_204_completion](bool clean) { + async_204_completion->record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/sync-304") + ([sync_304_calls, sync_304_completion](const crow::request&, crow::response& res) { + res.code = 304; + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.set_chunked_content_provider([sync_304_calls](std::string& chunk) { + sync_304_calls->fetch_add(1); + chunk = "forbidden"; + return false; + }); + res.set_chunked_completion_handler( + [sync_304_completion](bool clean) { + sync_304_completion->record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/async-304") + ([async_304_calls, async_304_completion](const crow::request&, crow::response& res) { + res.code = 304; + res.set_async_chunked_content_provider( + [async_304_calls](crow::response::async_chunk_completion_t complete) { + async_304_calls->fetch_add(1); + complete(crow::chunk_result::done, "forbidden"); + }); + res.set_chunked_completion_handler( + [async_304_completion](bool clean) { + async_304_completion->record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/sync-205") + ([sync_205_calls, sync_205_completion](const crow::request&, crow::response& res) { + res.code = 205; + res.set_header("Content-Encoding", "gzip"); + res.set_header("Trailer", "Digest"); + res.set_chunked_content_provider([sync_205_calls](std::string& chunk) { + sync_205_calls->fetch_add(1); + chunk = "forbidden"; + return false; + }); + res.set_chunked_completion_handler( + [sync_205_completion](bool clean) { + sync_205_completion->record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/async-205") + ([async_205_calls, async_205_completion](const crow::request&, crow::response& res) { + res.code = 205; + res.set_async_chunked_content_provider( + [async_205_calls](crow::response::async_chunk_completion_t complete) { + async_205_calls->fetch_add(1); + complete(crow::chunk_result::done, "forbidden"); + }); + res.set_chunked_completion_handler( + [async_205_completion](bool clean) { + async_205_completion->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + const auto request = [](const std::string& path) { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET " + path + " HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + std::string response; + const bool closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + asio_error_code close_error; + client.socket().close(close_error); + REQUIRE(closed); + return response; + }; + + const auto sync_204_response = request("/sync-204"); + const auto async_204_response = request("/async-204"); + const auto sync_304_response = request("/sync-304"); + const auto async_304_response = request("/async-304"); + const auto sync_205_response = request("/sync-205"); + const auto async_205_response = request("/async-205"); + + // A 205 with a provider on HTTP/1.0 is normalized to a bodyless 205, so the + // chunked-coding rejection must not fire. + HttpClient http_1_0_client(LOCALHOST_ADDRESS, 45451); + http_1_0_client.send("GET /sync-205 HTTP/1.0\r\nHost: localhost\r\n\r\n"); + std::string http_1_0_response; + const bool http_1_0_closed = receive_until_closed_with_deadline(http_1_0_client.socket(), http_1_0_response, std::chrono::seconds(5)); + asio_error_code http_1_0_close_error; + http_1_0_client.socket().close(http_1_0_close_error); + REQUIRE(http_1_0_closed); + server_shutdown.shutdown(); + + const auto check_bodyless = [](const std::string& response) { + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.size() == header_end + 4); + }; + check_bodyless(sync_204_response); + check_bodyless(async_204_response); + check_bodyless(sync_304_response); + check_bodyless(async_304_response); + check_bodyless(sync_205_response); + check_bodyless(async_205_response); + check_bodyless(http_1_0_response); + CHECK(sync_204_response.find("Transfer-Encoding:") == std::string::npos); + CHECK(async_204_response.find("Transfer-Encoding:") == std::string::npos); + CHECK(sync_304_response.find("Transfer-Encoding:") == std::string::npos); + CHECK(async_304_response.find("Transfer-Encoding:") == std::string::npos); + CHECK(sync_205_response.find("Transfer-Encoding:") == std::string::npos); + CHECK(async_205_response.find("Transfer-Encoding:") == std::string::npos); + CHECK(http_1_0_response.find("Transfer-Encoding:") == std::string::npos); + // 205 is empty rather than self-delimiting: without an explicit zero + // length a keep-alive client would wait for close-delimited content. + CHECK(sync_205_response.find("Content-Length: 0") != std::string::npos); + CHECK(async_205_response.find("Content-Length: 0") != std::string::npos); + CHECK(http_1_0_response.find(" 205 ") != std::string::npos); + // Crow sends no trailer section on any of these. 204 and 304 keep + // handler-set representation metadata; the forced-empty 205 drops a + // coding header that would misdescribe it. + CHECK(sync_204_response.find("Trailer:") == std::string::npos); + CHECK(sync_304_response.find("Trailer:") == std::string::npos); + CHECK(sync_205_response.find("Trailer:") == std::string::npos); + CHECK(sync_204_response.find("Content-Encoding: gzip") != std::string::npos); + CHECK(sync_304_response.find("Content-Encoding: gzip") != std::string::npos); + CHECK(sync_205_response.find("Content-Encoding:") == std::string::npos); + CHECK(sync_204_calls->load() == 0); + CHECK(async_204_calls->load() == 0); + CHECK(sync_304_calls->load() == 0); + CHECK(async_304_calls->load() == 0); + CHECK(sync_205_calls->load() == 0); + CHECK(async_205_calls->load() == 0); + CHECK(sync_204_completion->calls() == 1); + CHECK(async_204_completion->calls() == 1); + CHECK(sync_304_completion->calls() == 1); + CHECK(async_304_completion->calls() == 1); + // The sync-205 route was requested twice (HTTP/1.1 and HTTP/1.0). + CHECK(sync_205_completion->calls() == 2); + CHECK(async_205_completion->calls() == 1); + REQUIRE(sync_204_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + REQUIRE(async_204_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + REQUIRE(sync_304_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + REQUIRE(async_304_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + REQUIRE(sync_205_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + REQUIRE(async_205_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(sync_204_result.get() == true); + CHECK(async_204_result.get() == true); + CHECK(sync_304_result.get() == true); + CHECK(async_304_result.get() == true); + CHECK(sync_205_result.get() == true); + CHECK(async_205_result.get() == true); +} // bodyless_statuses_do_not_invoke_chunk_providers + + +#ifdef CROW_ENABLE_COMPRESSION +TEST_CASE("discarded_bodies_are_not_compressed") +{ + SimpleApp app; + CROW_ROUTE(app, "/interim-compressed") + ([](const crow::request&, crow::response& res) { + res.code = 199; + res.body = "interim details"; + res.end(); + }); + CROW_ROUTE(app, "/no-content-compressed") + ([](const crow::request&, crow::response& res) { + res.code = 204; + res.body = "discarded"; + res.end(); + }); + CROW_ROUTE(app, "/normal-compressed") + ([] { + return "full-length body that goes out compressed"; + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).use_compression(compression::algorithm::GZIP).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + const auto request = [](const std::string& path) { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET " + path + " HTTP/1.1\r\nHost: localhost\r\nAccept-Encoding: gzip\r\nConnection: close\r\n\r\n"); + std::string response; + const bool closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + asio_error_code close_error; + client.socket().close(close_error); + REQUIRE(closed); + return response; + }; + + const auto interim_response = request("/interim-compressed"); + const auto no_content_response = request("/no-content-compressed"); + const auto normal_response = request("/normal-compressed"); + server_shutdown.shutdown(); + + // A body that is about to be discarded is never compressed: the + // synthesized 500 and the bodyless 204 carry no Content-Encoding. + CHECK(interim_response.find(" 500 ") != std::string::npos); + CHECK(interim_response.find("Content-Encoding:") == std::string::npos); + CHECK(interim_response.find("Content-Length: 27\r\n") != std::string::npos); + CHECK(no_content_response.find(" 204 ") != std::string::npos); + CHECK(no_content_response.find("Content-Encoding:") == std::string::npos); + // The gate leaves ordinary responses untouched. + CHECK(normal_response.find("Content-Encoding: gzip") != std::string::npos); +} // discarded_bodies_are_not_compressed +#endif + + +TEST_CASE("deferred_chunked_response_replaced_by_static_file_closes_after_pipelined_input") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto provider_calls = std::make_shared>(0); + auto second_route_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/deferred-static-boundary") + ([deferred_end_promise, provider_calls, completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t) { + provider_calls->fetch_add(1); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + deferred_end_promise->set_value([&res] { + res.set_static_file_info("tests/img/cat.jpg"); + res.end(); + }); + }); + CROW_ROUTE(app, "/after-deferred-static-boundary") + ([second_route_calls](const crow::request&, crow::response& res) { + second_route_calls->fetch_add(1); + res.end("second-static"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests = "GET /deferred-static-boundary HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-deferred-static-boundary HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(second_route_calls->load() == 0); + deferred_end.get()(); + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(provider_calls->load() == 0); + CHECK(second_route_calls->load() == 0); + CHECK(response.find("second-static") == std::string::npos); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == true); + CHECK(completion_observation->calls() == 1); +} // deferred_chunked_response_replaced_by_static_file_closes_after_pipelined_input + + +TEST_CASE("async_chunked_response_closes_after_pipelined_input") +{ + SimpleApp app; + auto observation = std::make_shared(); + + CROW_ROUTE(app, "/async-close-on-pipeline") + ([observation](const crow::request&, crow::response& res) { + observation->first_route_calls.fetch_add(1); + res.set_async_chunked_content_provider( + [observation](crow::response::async_chunk_completion_t complete) { + observation->provider_calls.fetch_add(1); + observation->provider_completion.capture(std::move(complete)); + }); + res.set_chunked_completion_handler([observation](bool clean) { + observation->completion.record(clean); + }); + res.end(); + }); + CROW_ROUTE(app, "/after-async-close-on-pipeline") + ([observation](const crow::request&, crow::response& res) { + observation->second_route_calls.fetch_add(1); + res.end("second"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + auto completion_result = observation->completion.first_result(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests = "GET /async-close-on-pipeline HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-async-close-on-pipeline HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(observation->provider_completion.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(observation->provider_completion.complete(crow::chunk_result::done, "payload")); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + + // Pipelined bytes behind a streamed response: the stream finishes + // correctly, then the connection closes; the second request is not served. + REQUIRE(connection_closed); + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("7\r\npayload\r\n") != std::string::npos); + CHECK(response.find("0\r\n\r\n") != std::string::npos); + CHECK(observation->second_route_calls.load() == 0); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == true); +} // async_chunked_response_closes_after_pipelined_input + +TEST_CASE("async_chunked_response_aborts_when_peer_closes_after_early_input") +{ + SimpleApp app; + auto observation = std::make_shared(); + + CROW_ROUTE(app, "/early-input-then-close") + ([observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [observation](crow::response::async_chunk_completion_t complete) { + observation->provider_calls.fetch_add(1); + observation->provider_completion.capture(std::move(complete)); + }); + res.set_chunked_completion_handler([observation](bool clean) { + observation->completion.record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + auto completion_result = observation->completion.first_result(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /early-input-then-close HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + REQUIRE(observation->provider_completion.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + + // A stray early byte followed by a full close: the idle stream must not + // become an undetectable zombie holding the provider and the socket. + asio::write(client, asio::buffer(std::string("X"))); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + asio_error_code close_error; + client.close(close_error); + + const auto completion_status = completion_result.wait_for(std::chrono::seconds(3)); + REQUIRE(completion_status == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(observation->completion.calls() == 1); +} // async_chunked_response_aborts_when_peer_closes_after_early_input + +TEST_CASE("keep_alive_emission_respects_an_application_connection_header") +{ + SimpleApp app; + + CROW_ROUTE(app, "/app-connection-header") + ([](const crow::request&, crow::response& res) { + res.set_header("Connection", "close"); + res.end("owned"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /app-connection-header HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string response; + receive_with_deadline(client.socket(), response, std::chrono::seconds(3), has_complete_http_response); + + std::size_t connection_headers = 0; + std::string lowered; + lowered.resize(response.size()); + std::transform(response.begin(), response.end(), lowered.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + for (auto at = lowered.find("\nconnection:"); at != std::string::npos; at = lowered.find("\nconnection:", at + 1)) + ++connection_headers; + CHECK(connection_headers == 1); + CHECK(lowered.find("connection: close") != std::string::npos); + + asio_error_code close_error; + client.socket().close(close_error); +} // keep_alive_emission_respects_an_application_connection_header + +TEST_CASE("application_connection_close_closes_the_connection") +{ + SimpleApp app; + auto stream_completion = std::make_shared(); + auto stream_result = stream_completion->first_result(); + + CROW_ROUTE(app, "/plain-close") + ([](const crow::request&, crow::response& res) { + res.set_header("Connection", "Close"); + res.end("owned"); + }); + CROW_ROUTE(app, "/list-close") + ([](const crow::request&, crow::response& res) { + res.set_header("Connection", "keep-alive, Close"); + res.end("listed"); + }); + CROW_ROUTE(app, "/stream-close") + ([stream_completion](const crow::request&, crow::response& res) { + res.set_header("Connection", "close"); + int remaining = 1; + res.set_chunked_content_provider( + [remaining](std::string& chunk) mutable -> bool { + if (remaining == 0) + return false; + chunk = "payload"; + --remaining; + return true; + }); + res.set_chunked_completion_handler( + [stream_completion](bool clean) { + stream_completion->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + const auto closed_response = [](const std::string& request_text) { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send(request_text); + std::string response; + const bool closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + asio_error_code close_error; + client.socket().close(close_error); + REQUIRE(closed); + return response; + }; + + // A keep-alive HTTP/1.1 request: the application-supplied header alone + // must close the connection after the response. + const auto plain_response = closed_response("GET /plain-close HTTP/1.1\r\nHost: localhost\r\n\r\n"); + CHECK(plain_response.find("owned") != std::string::npos); + + // HTTP/1.0 with request-side keep-alive: the response header still wins. + const auto http_1_0_response = closed_response("GET /plain-close HTTP/1.0\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n"); + CHECK(http_1_0_response.find("owned") != std::string::npos); + + // The close token is honored inside a comma-separated list too. + const auto list_response = closed_response("GET /list-close HTTP/1.1\r\nHost: localhost\r\n\r\n"); + CHECK(list_response.find("listed") != std::string::npos); + + // The chunked path delivers the whole body and terminator, then closes. + const auto stream_response = closed_response("GET /stream-close HTTP/1.1\r\nHost: localhost\r\n\r\n"); + CHECK(stream_response.find("7\r\npayload\r\n") != std::string::npos); + CHECK(stream_response.find("0\r\n\r\n") != std::string::npos); + server_shutdown.shutdown(); + + REQUIRE(stream_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(stream_result.get() == true); + CHECK(stream_completion->calls() == 1); +} // application_connection_close_closes_the_connection + + +TEST_CASE("later_packet_request_is_served_after_an_ordinary_deferred_response") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + + CROW_ROUTE(app, "/later-packet-first") + ([deferred_end_promise](const crow::request&, crow::response& res) { + deferred_end_promise->set_value([&res] { + res.end("first"); + }); + }); + CROW_ROUTE(app, "/later-packet-second") + ([] { + return "second"; + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /later-packet-first HTTP/1.1\r\nHost: localhost\r\n\r\n"); + REQUIRE(deferred_end.wait_for(std::chrono::seconds(3)) == std::future_status::ready); + + // The second request arrives in a separate packet while the first + // response is deferred: it is served afterwards as a sequential request. + client.send("GET /later-packet-second HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + deferred_end.get()(); + + std::string response; + REQUIRE(receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5))); + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + const auto first_at = response.find("first"); + const auto second_at = response.find("second"); + REQUIRE(first_at != std::string::npos); + REQUIRE(second_at != std::string::npos); + CHECK(first_at < second_at); +} // later_packet_request_is_served_after_an_ordinary_deferred_response + + +struct ExecutorStampLocalMiddleware : crow::ILocalMiddleware +{ + struct context + {}; + static inline std::shared_ptr> after_handle_thread; + void before_handle(crow::request&, crow::response&, context&) {} + void after_handle(crow::request&, crow::response&, context&) + { + if (auto stamp = after_handle_thread) + { + after_handle_thread = nullptr; + stamp->set_value(std::this_thread::get_id()); + } + } +}; + +struct ThrowingLocalMiddleware : crow::ILocalMiddleware +{ + struct context + {}; + void before_handle(crow::request&, crow::response&, context&) {} + void after_handle(crow::request&, crow::response&, context&) + { + throw std::runtime_error("after_handle failure"); + } +}; + +TEST_CASE("route_local_after_handlers_run_on_the_connection_executor") +{ + crow::App app; + ExecutorStampLocalMiddleware::after_handle_thread = std::make_shared>(); + auto after_handle_thread = ExecutorStampLocalMiddleware::after_handle_thread->get_future(); + auto handler_thread_promise = std::make_shared>(); + auto handler_thread = handler_thread_promise->get_future(); + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + + CROW_ROUTE(app, "/local-mw-deferred") + .middlewares()( + [handler_thread_promise, deferred_end_promise](const crow::request&, crow::response& res) { + handler_thread_promise->set_value(std::this_thread::get_id()); + deferred_end_promise->set_value([&res] { + res.end("deferred"); + }); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).concurrency(1).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /local-mw-deferred HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + REQUIRE(deferred_end.wait_for(std::chrono::seconds(3)) == std::future_status::ready); + + // end() runs on a foreign thread; the route-local after handler must not. + std::thread foreign_end_thread([&deferred_end] { + deferred_end.get()(); + }); + const auto foreign_thread_id = foreign_end_thread.get_id(); + + std::string response; + REQUIRE(receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5))); + asio_error_code close_error; + client.socket().close(close_error); + foreign_end_thread.join(); + server_shutdown.shutdown(); + + CHECK(response.find("deferred") != std::string::npos); + REQUIRE(after_handle_thread.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + const auto after_thread_id = after_handle_thread.get(); + CHECK(after_thread_id != foreign_thread_id); + REQUIRE(handler_thread.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(after_thread_id == handler_thread.get()); +} // route_local_after_handlers_run_on_the_connection_executor + + +TEST_CASE("throwing_route_local_after_handler_still_completes_the_response") +{ + crow::App app; + + CROW_ROUTE(app, "/local-mw-throwing") + .middlewares()( + [](const crow::request&, crow::response& res) { + res.end("survived"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /local-mw-throwing HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + std::string response; + REQUIRE(receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5))); + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + CHECK(response.find("survived") != std::string::npos); +} // throwing_route_local_after_handler_still_completes_the_response + + +TEST_CASE("is_alive_is_readable_from_a_foreign_thread_while_deferred") +{ + SimpleApp app; + auto deferred_response_promise = std::make_shared>(); + auto deferred_response = deferred_response_promise->get_future(); + + CROW_ROUTE(app, "/deferred-is-alive") + ([deferred_response_promise](const crow::request&, crow::response& res) { + deferred_response_promise->set_value(&res); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /deferred-is-alive HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + REQUIRE(deferred_response.wait_for(std::chrono::seconds(3)) == std::future_status::ready); + crow::response* res = deferred_response.get(); + + // The documented any-thread window: after the handler returned with the + // response deferred and before end(). + std::atomic alive_observations{0}; + std::thread poller([res, &alive_observations] { + for (int i = 0; i < 100; ++i) + { + if (res->is_alive()) + alive_observations.fetch_add(1); + } + }); + poller.join(); + res->end("polled"); + + std::string response; + REQUIRE(receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5))); + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + CHECK(response.find("polled") != std::string::npos); + CHECK(alive_observations.load() == 100); +} // is_alive_is_readable_from_a_foreign_thread_while_deferred + + +struct BodyStampMiddleware +{ + struct context + {}; + void before_handle(crow::request&, crow::response&, context&) {} + void after_handle(crow::request&, crow::response& res, context&) + { + res.body += "-stamped"; + } +}; + +TEST_CASE("head_content_length_matches_the_post_middleware_representation") +{ + crow::App app; + + CROW_ROUTE(app, "/stamped") + .methods("GET"_method, "HEAD"_method)([](const crow::request&, crow::response& res) { + res.end("base"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + + const auto request = [](const std::string& method) { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send(method + " /stamped HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); + std::string response; + const bool closed = receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5)); + asio_error_code close_error; + client.socket().close(close_error); + REQUIRE(closed); + return response; + }; + + const auto get_response = request("GET"); + const auto head_response = request("HEAD"); + server_shutdown.shutdown(); + + // Middleware appended to the body, so HEAD must report the length of the + // representation the equivalent GET actually sends. + CHECK(get_response.find("base-stamped") != std::string::npos); + CHECK(get_response.find("Content-Length: 12\r\n") != std::string::npos); + CHECK(head_response.find("Content-Length: 12\r\n") != std::string::npos); + const auto head_header_end = head_response.find("\r\n\r\n"); + REQUIRE(head_header_end != std::string::npos); + CHECK(head_response.size() == head_header_end + 4); +} // head_content_length_matches_the_post_middleware_representation + + +TEST_CASE("chunked_header_write_runs_under_the_write_deadline") +{ + SimpleApp app; + PausingSocketContext socket_context; + socket_context.pause_next_write(); + auto header_write_pending = socket_context.pending_write_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/paused-header-stream") + ([completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [](crow::response::async_chunk_completion_t) { + // The provider stays idle: the header write is the phase under test. + }); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using PausedWriteServer = crow::Server; + PausedWriteServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 1, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + asio::write(client, asio::buffer(std::string("GET /paused-header-stream HTTP/1.1\r\nHost: localhost\r\n\r\n"))); + REQUIRE(header_write_pending.wait_for(std::chrono::seconds(3)) == std::future_status::ready); + + // The parked header write runs under the connection deadline: the timer + // closes the socket, so the client observes EOF instead of a silent hang. + std::string response; + REQUIRE(receive_until_closed_with_deadline(client, response, std::chrono::seconds(5))); + asio_error_code close_error; + client.close(close_error); + + socket_context.resume_pending_write(); + REQUIRE(completion_result.wait_for(std::chrono::seconds(3)) == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(completion_observation->calls() == 1); + server_shutdown.shutdown(); +} // chunked_header_write_runs_under_the_write_deadline + +TEST_CASE("concurrent_end_calls_deliver_exactly_one_body") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + + CROW_ROUTE(app, "/racing-end") + ([deferred_end_promise](const crow::request&, crow::response& res) { + deferred_end_promise->set_value(std::ref(res)); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + for (int round = 0; round < 60; ++round) + { + auto fresh_promise = std::make_shared>>(); + *deferred_end_promise = std::move(*fresh_promise); + auto deferred = deferred_end_promise->get_future(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /racing-end HTTP/1.1\r\nHost: localhost\r\n\r\n"); + REQUIRE(deferred.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + crow::response& res = deferred.get(); + + std::atomic go{0}; + std::thread first([&] { + go.fetch_add(1); + while (go.load() < 2) {} + res.end("alpha"); + }); + std::thread second([&] { + go.fetch_add(1); + while (go.load() < 2) {} + res.end("bravo"); + }); + first.join(); + second.join(); + + std::string response; + REQUIRE(receive_with_deadline(client.socket(), response, std::chrono::seconds(3), has_complete_http_response)); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + const std::string body = response.substr(header_end + 4); + // Only the first end() takes effect: the body is one of the two + // candidates, never a concatenation. + const bool single_body = body == "alpha" || body == "bravo"; + REQUIRE(single_body); + asio_error_code close_error; + client.socket().close(close_error); + } +} // concurrent_end_calls_deliver_exactly_one_body + +TEST_CASE("max_stream_chunk_size_zero_means_unlimited") +{ + SimpleApp app; + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/uncapped-chunk") + ([completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [](crow::response::async_chunk_completion_t complete) { + complete(crow::chunk_result::done, std::string(64u * 1024u, 'y')); + }); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).max_stream_chunk_size(0).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /uncapped-chunk HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + REQUIRE(completion_result.wait_for(std::chrono::seconds(2)) == std::future_status::ready); + CHECK(completion_result.get() == true); + + asio_error_code close_error; + client.socket().close(close_error); +} // max_stream_chunk_size_zero_means_unlimited + + +TEST_CASE("deferred_chunked_response_closes_after_pipelined_input") +{ + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto second_route_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/deferred-close-on-pipeline") + ([deferred_end_promise, completion_observation](const crow::request&, crow::response& res) { + res.set_chunked_content_provider( + [sent = false](std::string& chunk) mutable -> bool { + if (sent) + return false; + chunk = "payload"; + sent = true; + return true; + }, + "text/plain"); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + deferred_end_promise->set_value([&res] { + res.end(); + }); + }); + CROW_ROUTE(app, "/after-deferred-close-on-pipeline") + ([second_route_calls](const crow::request&, crow::response& res) { + second_route_calls->fetch_add(1); + res.end("second"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests = "GET /deferred-close-on-pipeline HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-deferred-close-on-pipeline HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + deferred_end.get()(); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + + REQUIRE(connection_closed); + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("7\r\npayload\r\n") != std::string::npos); + CHECK(response.find("0\r\n\r\n") != std::string::npos); + CHECK(second_route_calls->load() == 0); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == true); +} // deferred_chunked_response_closes_after_pipelined_input + + +TEST_CASE("static_file_failure_reports_unclean_chunk_completion") +{ + const auto unique_suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::string file_path = "crow-static-completion-" + std::to_string(unique_suffix) + ".tmp"; + { + std::ofstream file(file_path, std::ios::binary); + REQUIRE(file.good()); + file << "static-body"; + } + + SimpleApp app; + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto removal_result = std::make_shared>(-1); + + CROW_ROUTE(app, "/missing-static") + ([file_path, completion_observation, removal_result](crow::response& res) { + res.set_async_chunked_content_provider([](crow::response::async_chunk_completion_t) {}); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.set_static_file_info_unsafe(file_path); + removal_result->store(std::remove(file_path.c_str())); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45463).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45463)); + const std::string request = "GET /missing-static HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(removal_result->load() == 0); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(completion_observation->calls() == 1); +} // static_file_failure_reports_unclean_chunk_completion + + +TEST_CASE("throwing_route_reports_chunk_completion_through_the_error_response") +{ + SimpleApp app; + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto provider_calls = std::make_shared>(0); + + CROW_ROUTE(app, "/throws-after-installing-a-provider") + ([completion_observation, provider_calls](crow::response& res) { + res.set_async_chunked_content_provider([provider_calls](crow::response::async_chunk_completion_t) { + provider_calls->fetch_add(1); + }); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + throw std::runtime_error("route failure after installing a chunk provider"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45473).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45473)); + const std::string request = + "GET /throws-after-installing-a-provider HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(response.find("HTTP/1.1 500") == 0); + CHECK(response.find("Transfer-Encoding") == std::string::npos); + CHECK(provider_calls->load() == 0); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == true); + CHECK(completion_observation->calls() == 1); +} // throwing_route_reports_chunk_completion_through_the_error_response + + +TEST_CASE("deferred_chunked_response_replaced_by_large_body_closes_after_pipelined_input") +{ + SimpleApp app; + app.stream_threshold(8); + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto provider_calls = std::make_shared>(0); + auto second_route_calls = std::make_shared>(0); + auto first_body = std::make_shared(20000, 'x'); + + CROW_ROUTE(app, "/deferred-large-body-boundary") + ([deferred_end_promise, provider_calls, first_body](const crow::request& req, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t) { provider_calls->fetch_add(1); }); + asio::post(*req.io_context, [deferred_end_promise, &res, first_body] { + deferred_end_promise->set_value([&res, first_body] { + res.clear(); + res.end(*first_body); + }); + }); + }); + CROW_ROUTE(app, "/after-deferred-large-body-boundary") + ([second_route_calls](const crow::request&, crow::response& res) { + second_route_calls->fetch_add(1); + res.end("second-large-body"); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { app.stop(); }); + app.wait_for_server_start(); + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests + = "GET /deferred-large-body-boundary HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-deferred-large-body-boundary HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(second_route_calls->load() == 0); + deferred_end.get()(); + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(provider_calls->load() == 0); + CHECK(second_route_calls->load() == 0); + CHECK(response.find(*first_body) != std::string::npos); + CHECK(response.find("second-large-body") == std::string::npos); +} // deferred_chunked_response_replaced_by_large_body_closes_after_pipelined_input + + +TEST_CASE("failed_regular_response_discards_retained_pipelined_input") { + SimpleApp app; + auto deferred_end_promise = std::make_shared>>(); + auto deferred_end = deferred_end_promise->get_future(); + auto second_route_calls = std::make_shared>(0); + PausingSocketContext socket_context; + + CROW_ROUTE(app, "/failing-regular-response") + ([deferred_end_promise, &socket_context](const crow::request& req, crow::response& res) { + res.set_async_chunked_content_provider([](crow::response::async_chunk_completion_t) {}); + asio::post(*req.io_context, [deferred_end_promise, &res, &socket_context] { + deferred_end_promise->set_value([&res, &socket_context] { + res.clear(); + socket_context.fail_next_write(); + res.end("first"); + }); + }); + }); + CROW_ROUTE(app, "/after-failing-regular-response") + ([second_route_calls](const crow::request&, crow::response& res) { + second_route_calls->fetch_add(1); + res.end("unexpected"); + }); + + app.validate(); + std::tuple<> middlewares; + using RegularWriteErrorServer = crow::Server; + RegularWriteErrorServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string requests + = "GET /failing-regular-response HTTP/1.1\r\nHost: localhost\r\n\r\n" + "GET /after-failing-regular-response HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(client, asio::buffer(requests)); + + REQUIRE(deferred_end.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(second_route_calls->load() == 0); + deferred_end.get()(); + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(second_route_calls->load() == 0); + CHECK(response.find("unexpected") == std::string::npos); +} // failed_regular_response_discards_retained_pipelined_input + + +TEST_CASE("async_chunked_response_closes_after_input_arrives_while_provider_waits") +{ + SimpleApp app; + + auto observation = std::make_shared(); + PausingSocketContext socket_context; + auto waiting_read_started = socket_context.observed_read_started_future(); + auto waiting_read_completed = socket_context.observed_read_completed_future(); + + CROW_ROUTE(app, "/waiting-pipeline") + ([observation, &socket_context](const crow::request&, crow::response& res) { + observation->first_route_calls.fetch_add(1); + res.set_header("X-Pipeline-Response", "waiting"); + res.set_async_chunked_content_provider( + [observation, &socket_context](crow::response::async_chunk_completion_t complete) { + observation->provider_calls.fetch_add(1); + socket_context.observe_next_read(); + observation->provider_completion.capture(std::move(complete)); + }); + res.set_chunked_completion_handler([observation](bool clean) { + observation->first_completion_seen.store(true); + observation->completion.record(clean); + }); + res.end(); + }); + + CROW_ROUTE(app, "/after-waiting-pipeline") + ([observation](const crow::request&, crow::response& res) { + observation->second_route_calls.fetch_add(1); + if (!observation->first_completion_seen.load()) + observation->second_route_overlapped.store(true); + res.end("after-wait"); + }); + + app.validate(); + std::tuple<> middlewares; + using ObservedReadServer = crow::Server; + ObservedReadServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string first_request = "GET /waiting-pipeline HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(first_request)); + + auto completion_result = observation->completion.first_result(); + const auto provider_status = observation->provider_completion.wait_for(std::chrono::seconds(5)); + const auto read_start_status = waiting_read_started.wait_for(std::chrono::seconds(1)); + const std::string second_request = "GET /after-waiting-pipeline HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio_error_code pipeline_write_error; + asio::write(client, asio::buffer(second_request), pipeline_write_error); + const auto read_completion_status = waiting_read_completed.wait_for(std::chrono::seconds(1)); + const std::size_t retained_bytes = read_completion_status == std::future_status::ready ? waiting_read_completed.get() : 0; + const bool second_route_called_while_waiting = observation->second_route_calls.load() != 0; + const bool result_accepted = provider_status == std::future_status::ready ? observation->provider_completion.complete(crow::chunk_result::done, "waiting") : false; + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : false; + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(provider_status == std::future_status::ready); + REQUIRE(read_start_status == std::future_status::ready); + CHECK_FALSE(pipeline_write_error); + REQUIRE(read_completion_status == std::future_status::ready); + CHECK(retained_bytes > 0); + CHECK(retained_bytes <= second_request.size()); + CHECK_FALSE(second_route_called_while_waiting); + CHECK(result_accepted); + REQUIRE(connection_closed); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean); + CHECK(observation->first_route_calls.load() == 1); + CHECK(observation->provider_calls.load() == 1); + CHECK(observation->completion.calls() == 1); + CHECK(observation->second_route_calls.load() == 0); + + const auto first_header_end = response.find("\r\n\r\n"); + REQUIRE(first_header_end != std::string::npos); + const auto first_body_begin = first_header_end + 4; + const std::string first_body = "7\r\nwaiting\r\n0\r\n\r\n"; + REQUIRE(response.compare(first_body_begin, first_body.size(), first_body) == 0); + CHECK(response.size() == first_body_begin + first_body.size()); +} // async_chunked_response_closes_after_input_arrives_while_provider_waits + + +TEST_CASE("async_chunked_response_keeps_header_storage_stable_after_end") +{ + SimpleApp app; + + PausingSocketContext socket_context; + socket_context.pause_next_write(); + auto header_write_pending = socket_context.pending_write_future(); + + CROW_ROUTE(app, "/stable-async-headers") + ([](const crow::request&, crow::response& res) { + res.set_header("Connection", "custom-keep-alive-value-with-owned-storage"); + res.set_async_chunked_content_provider( + [](crow::response::async_chunk_completion_t complete) { + complete(crow::chunk_result::done, "body"); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using PausedWriteServer = crow::Server; + PausedWriteServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /stable-async-headers HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + REQUIRE(header_write_pending.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + socket_context.resume_pending_write(); + std::string response; + const bool response_complete = receive_with_deadline(client, response, std::chrono::seconds(5), has_chunk_terminator); + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(response_complete); + CHECK(response.find("Connection: custom-keep-alive-value-with-owned-storage\r\n") != std::string::npos); + CHECK(response.find("4\r\nbody\r\n0\r\n\r\n") != std::string::npos); +} // async_chunked_response_keeps_header_storage_stable_after_end + + +TEST_CASE("async_chunked_response_completed_from_other_threads") { + SimpleApp app; + + auto next_chunk = std::make_shared>(0); + auto active_requests = std::make_shared>(0); + auto maximum_active_requests = std::make_shared>(0); + auto worker_tasks = std::make_shared>>(); + auto worker_tasks_mutex = std::make_shared(); + auto completion_clean = std::make_shared>(); + + CROW_ROUTE(app, "/async-chunks") + ([next_chunk, active_requests, maximum_active_requests, worker_tasks, worker_tasks_mutex, completion_clean]( + const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [next_chunk, active_requests, maximum_active_requests, worker_tasks, worker_tasks_mutex]( + crow::response::async_chunk_completion_t complete) { + const std::size_t index = next_chunk->fetch_add(1); + const std::size_t pending = active_requests->fetch_add(1) + 1; + std::size_t observed_maximum = maximum_active_requests->load(); + while (observed_maximum < pending + && !maximum_active_requests->compare_exchange_weak(observed_maximum, pending)) { + } + + std::lock_guard lock(*worker_tasks_mutex); + worker_tasks->emplace_back( + std::async(std::launch::async, [index, active_requests, complete = std::move(complete)]() mutable { + active_requests->fetch_sub(1); + if (index < 2) { + complete(crow::chunk_result::more, "part" + std::to_string(index + 1)); + } else { + complete(crow::chunk_result::done, "part3"); + } + })); + }, + "text/plain"); + res.set_chunked_completion_handler([completion_clean](bool clean) { completion_clean->set_value(clean); }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /async-chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + const bool response_complete = receive_with_deadline(client.socket(), response, std::chrono::seconds(5), has_chunk_terminator); + + std::vector> tasks; + { + std::lock_guard lock(*worker_tasks_mutex); + tasks.swap(*worker_tasks); + } + for (auto& task : tasks) + task.get(); + + auto completion = completion_clean->get_future(); + const auto completion_status = completion.wait_for(std::chrono::seconds(5)); + const bool clean = completion_status == std::future_status::ready ? completion.get() : false; + + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(response_complete); + CHECK(next_chunk->load() == 3); + CHECK(active_requests->load() == 0); + CHECK(maximum_active_requests->load() == 1); + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + CHECK(response.find("Content-Type: text/plain") != std::string::npos); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4) == "5\r\npart1\r\n5\r\npart2\r\n5\r\npart3\r\n0\r\n\r\n"); + + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == true); +} // async_chunked_response_completed_from_other_threads + + +TEST_CASE("async_chunked_response_synchronous_completion_does_not_recurse") { + SimpleApp app; + + const std::size_t chunk_count = 256; + auto calls = std::make_shared>(0); + auto current_depth = std::make_shared>(0); + auto maximum_depth = std::make_shared>(0); + + CROW_ROUTE(app, "/synchronous-async-chunks") + ([calls, current_depth, maximum_depth, chunk_count](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [calls, current_depth, maximum_depth, chunk_count](crow::response::async_chunk_completion_t complete) { + const std::size_t depth = current_depth->fetch_add(1) + 1; + std::size_t observed_maximum = maximum_depth->load(); + while (observed_maximum < depth && !maximum_depth->compare_exchange_weak(observed_maximum, depth)) { + } + + const std::size_t index = calls->fetch_add(1); + if (index < chunk_count) { + complete(crow::chunk_result::more, ""); + } else { + complete(crow::chunk_result::done, ""); + } + current_depth->fetch_sub(1); + }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /synchronous-async-chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + const bool response_complete = receive_with_deadline(client.socket(), response, std::chrono::seconds(5), has_chunk_terminator); + + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(response_complete); + CHECK(calls->load() == chunk_count + 1); + CHECK(maximum_depth->load() == 1); +} // async_chunked_response_synchronous_completion_does_not_recurse + + +TEST_CASE("async_chunked_response_waits_beyond_connection_timeout") { + SimpleApp app; + + auto worker_tasks = std::make_shared>>(); + auto worker_tasks_mutex = std::make_shared(); + auto completion_clean = std::make_shared>(); + auto completion = completion_clean->get_future(); + + CROW_ROUTE(app, "/delayed-async-chunk") + ([worker_tasks, worker_tasks_mutex, completion_clean](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [worker_tasks, worker_tasks_mutex](crow::response::async_chunk_completion_t complete) { + std::lock_guard lock(*worker_tasks_mutex); + worker_tasks->emplace_back(std::async(std::launch::async, [complete = std::move(complete)]() mutable { + std::this_thread::sleep_for(std::chrono::milliseconds(2500)); + complete(crow::chunk_result::done, "delayed"); + })); + }, + "text/plain"); + res.set_chunked_completion_handler([completion_clean](bool clean) { completion_clean->set_value(clean); }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).timeout(1).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /delayed-async-chunk HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + const bool response_complete = receive_with_deadline(client.socket(), response, std::chrono::seconds(5), has_chunk_terminator); + + std::vector> tasks; + { + std::lock_guard lock(*worker_tasks_mutex); + tasks.swap(*worker_tasks); + } + for (auto& task : tasks) + task.get(); + + const auto completion_status = completion.wait_for(std::chrono::seconds(5)); + const bool clean = completion_status == std::future_status::ready ? completion.get() : false; + + asio_error_code close_error; + client.socket().close(close_error); + server_shutdown.shutdown(); + + REQUIRE(response_complete); + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + CHECK(response.find("Content-Type: text/plain") != std::string::npos); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4) == "7\r\ndelayed\r\n0\r\n\r\n"); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == true); +} // async_chunked_response_waits_beyond_connection_timeout + + +TEST_CASE("async_chunked_response_wait_does_not_block_other_routes") { + SimpleApp app; + + auto provider_started_promise = std::make_shared>(); + auto provider_started = provider_started_promise->get_future(); + auto completion_mutex = std::make_shared(); + auto delayed_completion = std::make_shared(); + + CROW_ROUTE(app, "/waiting-async-chunk") + ([provider_started_promise, completion_mutex, delayed_completion](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_started_promise, completion_mutex, delayed_completion]( + crow::response::async_chunk_completion_t complete) { + { + std::lock_guard lock(*completion_mutex); + *delayed_completion = std::move(complete); + } + provider_started_promise->set_value(); + }, + "text/plain"); + res.end(); + }); + + CROW_ROUTE(app, "/ready-while-stream-waits") + ([] { return "ready"; }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).concurrency(2).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app, completion_mutex, delayed_completion] { + crow::response::async_chunk_completion_t complete; + { + std::lock_guard lock(*completion_mutex); + complete = std::move(*delayed_completion); + } + if (complete) + complete(crow::chunk_result::abort, ""); + app.stop(); + }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket streaming_client(io_context); + streaming_client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string streaming_request = "GET /waiting-async-chunk HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(streaming_client, asio::buffer(streaming_request)); + + const auto provider_status = provider_started.wait_for(std::chrono::seconds(5)); + + asio::ip::tcp::socket regular_client(io_context); + regular_client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string regular_request + = "GET /ready-while-stream-waits HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + asio::write(regular_client, asio::buffer(regular_request)); + std::string regular_response; + const bool regular_response_complete + = receive_with_deadline(regular_client, regular_response, std::chrono::seconds(1), has_complete_http_response); + + crow::response::async_chunk_completion_t complete; + if (provider_status == std::future_status::ready) { + std::lock_guard lock(*completion_mutex); + complete = std::move(*delayed_completion); + } + if (complete) + complete(crow::chunk_result::done, "delayed"); + + const auto has_chunk_terminator = [](const std::string& response) { + return response.size() >= 5 && response.compare(response.size() - 5, 5, "0\r\n\r\n") == 0; + }; + std::string streaming_response; + const bool streaming_response_complete + = receive_with_deadline(streaming_client, streaming_response, std::chrono::seconds(5), has_chunk_terminator); + + asio_error_code close_error; + regular_client.close(close_error); + streaming_client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(provider_status == std::future_status::ready); + REQUIRE(regular_response_complete); + const auto regular_header_end = regular_response.find("\r\n\r\n"); + REQUIRE(regular_header_end != std::string::npos); + CHECK(regular_response.substr(regular_header_end + 4) == "ready"); + + REQUIRE(streaming_response_complete); + const auto header_end = streaming_response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(streaming_response.substr(header_end + 4) == "7\r\ndelayed\r\n0\r\n\r\n"); +} // async_chunked_response_wait_does_not_block_other_routes + + +TEST_CASE("async_chunked_response_backpressures_provider_until_prior_write_completes") { + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto active_provider_calls = std::make_shared>(0); + auto maximum_active_provider_calls = std::make_shared>(0); + auto second_provider_call_promise = std::make_shared>(); + auto second_provider_call = second_provider_call_promise->get_future(); + auto connection_io_context_promise = std::make_shared>(); + auto connection_io_context = connection_io_context_promise->get_future(); + PausingSocketContext socket_context; + auto pending_write = socket_context.pending_write_future(); + + CROW_ROUTE(app, "/backpressured-async-chunks") + ([provider_calls, + active_provider_calls, + maximum_active_provider_calls, + second_provider_call_promise, + connection_io_context_promise, + &socket_context](const crow::request& req, crow::response& res) { + connection_io_context_promise->set_value(req.io_context); + res.set_async_chunked_content_provider( + [provider_calls, + active_provider_calls, + maximum_active_provider_calls, + second_provider_call_promise, + &socket_context](crow::response::async_chunk_completion_t complete) { + const std::size_t active = active_provider_calls->fetch_add(1) + 1; + std::size_t observed_maximum = maximum_active_provider_calls->load(); + while (observed_maximum < active + && !maximum_active_provider_calls->compare_exchange_weak(observed_maximum, active)) { + } + + const std::size_t call = provider_calls->fetch_add(1); + if (call == 0) { + socket_context.pause_next_write(); + complete(crow::chunk_result::more, "first"); + } else if (call == 1) { + second_provider_call_promise->set_value(); + complete(crow::chunk_result::done, ""); + } else { + complete(crow::chunk_result::abort, ""); + } + active_provider_calls->fetch_sub(1); + }, + "application/octet-stream"); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using BackpressureServer = crow::Server; + BackpressureServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server, &socket_context] { + socket_context.resume_pending_write(); + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /backpressured-async-chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + const auto pending_write_status = pending_write.wait_for(std::chrono::seconds(5)); + const auto connection_io_context_status = connection_io_context.wait_for(std::chrono::seconds(5)); + auto executor_barrier_promise = std::make_shared>(); + auto executor_barrier = executor_barrier_promise->get_future(); + if (connection_io_context_status == std::future_status::ready) { + auto* executor = connection_io_context.get(); + asio::post(*executor, [executor, executor_barrier_promise] { + asio::post(*executor, [executor_barrier_promise] { executor_barrier_promise->set_value(); }); + }); + } + const auto executor_barrier_status = executor_barrier.wait_for(std::chrono::seconds(5)); + const std::size_t provider_calls_before_write_completion = provider_calls->load(); + + socket_context.resume_pending_write(); + + const auto has_chunk_terminator = [](const std::string& response) { + return response.size() >= 5 && response.compare(response.size() - 5, 5, "0\r\n\r\n") == 0; + }; + std::string response; + const bool response_complete + = receive_with_deadline(client, response, std::chrono::seconds(5), has_chunk_terminator); + + const auto second_call_after_prior_write = second_provider_call.wait_for(std::chrono::seconds(5)); + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(pending_write_status == std::future_status::ready); + REQUIRE(connection_io_context_status == std::future_status::ready); + REQUIRE(executor_barrier_status == std::future_status::ready); + CHECK(provider_calls_before_write_completion == 1); + REQUIRE(response_complete); + REQUIRE(second_call_after_prior_write == std::future_status::ready); + CHECK(provider_calls->load() == 2); + CHECK(active_provider_calls->load() == 0); + CHECK(maximum_active_provider_calls->load() == 1); + + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + const std::string encoded_body = response.substr(header_end + 4); + REQUIRE(encoded_body.size() == std::string("5\r\nfirst\r\n0\r\n\r\n").size()); + CHECK(encoded_body == "5\r\nfirst\r\n0\r\n\r\n"); +} // async_chunked_response_backpressures_provider_until_prior_write_completes + + +TEST_CASE("async_chunked_response_abort_closes_without_terminator_and_completes_once_unclean") { + SimpleApp app; + + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/aborted-async-chunks") + ([completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider([](crow::response::async_chunk_completion_t complete) { + complete(crow::chunk_result::abort, "discarded"); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { completion_observation->record(clean); }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { app.stop(); }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /aborted-async-chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).find("0\r\n\r\n") == std::string::npos); + CHECK(response.find("discarded") == std::string::npos); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_abort_closes_without_terminator_and_completes_once_unclean + + +TEST_CASE("async_chunked_response_recovers_abort_after_foreign_thread_publication_failure") { + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto publication_attempts = std::make_shared>(0); + auto transfer_lifetime_promise = std::make_shared>>(); + auto transfer_lifetime_result = transfer_lifetime_promise->get_future(); + auto worker_task_promise = std::make_shared>>(); + auto worker_task_result = worker_task_promise->get_future(); + auto connection_thread_promise = std::make_shared>(); + auto connection_thread_result = connection_thread_promise->get_future(); + auto provider_thread_promise = std::make_shared>(); + auto provider_thread_result = provider_thread_promise->get_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto completion_thread = completion_observation->first_thread(); + PausingSocketContext socket_context; + auto connection_destroyed = socket_context.started_connection_destroyed_future(); + ScopedAsyncChunkPublicationTestHook publication_hook([publication_attempts] { + if (publication_attempts->fetch_add(1) == 0) + throw std::runtime_error("forced asynchronous chunk publication failure"); + }); + + CROW_ROUTE(app, "/async-completion-publication-failure") + ([provider_calls, + transfer_lifetime_promise, + worker_task_promise, + connection_thread_promise, + provider_thread_promise, + completion_observation](const crow::request&, crow::response& res) { + connection_thread_promise->set_value(std::this_thread::get_id()); + auto transfer_lifetime = std::make_shared(0); + transfer_lifetime_promise->set_value(std::weak_ptr(transfer_lifetime)); + res.set_async_chunked_content_provider( + [provider_calls, transfer_lifetime, worker_task_promise, provider_thread_promise]( + crow::response::async_chunk_completion_t complete) { + static_cast(transfer_lifetime); + provider_calls->fetch_add(1); + worker_task_promise->set_value( + std::async(std::launch::async, [provider_thread_promise, complete = std::move(complete)]() mutable { + provider_thread_promise->set_value(std::this_thread::get_id()); + return complete(crow::chunk_result::done, "discarded"); + })); + }, + "text/plain"); + res.set_chunked_completion_handler([completion_observation, transfer_lifetime](bool clean) { + static_cast(transfer_lifetime); + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using PublicationFailureServer = crow::Server; + PublicationFailureServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /async-completion-publication-failure HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + const auto worker_task_status = worker_task_result.wait_for(std::chrono::seconds(1)); + std::future worker_task; + if (worker_task_status == std::future_status::ready) + worker_task = worker_task_result.get(); + const auto worker_completion_status + = worker_task.valid() ? worker_task.wait_for(std::chrono::seconds(1)) : std::future_status::deferred; + std::exception_ptr worker_exception; + bool publication_accepted = true; + if (worker_completion_status == std::future_status::ready) { + try { + publication_accepted = worker_task.get(); + } catch (...) { + worker_exception = std::current_exception(); + } + } + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + const auto connection_thread_status = connection_thread_result.wait_for(std::chrono::seconds(1)); + const auto connection_thread + = connection_thread_status == std::future_status::ready ? connection_thread_result.get() : std::thread::id{}; + const auto provider_thread_status = provider_thread_result.wait_for(std::chrono::seconds(1)); + const auto provider_thread + = provider_thread_status == std::future_status::ready ? provider_thread_result.get() : std::thread::id{}; + const auto completion_thread_status = completion_thread.wait_for(std::chrono::seconds(1)); + const auto completion_thread_id + = completion_thread_status == std::future_status::ready ? completion_thread.get() : std::thread::id{}; + const auto transfer_lifetime_status = transfer_lifetime_result.wait_for(std::chrono::seconds(1)); + std::weak_ptr transfer_lifetime; + if (transfer_lifetime_status == std::future_status::ready) + transfer_lifetime = transfer_lifetime_result.get(); + const auto connection_destroyed_status = connection_destroyed.wait_for(std::chrono::seconds(1)); + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).find("discarded") == std::string::npos); + CHECK(response.substr(header_end + 4).find("0\r\n\r\n") == std::string::npos); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); + CHECK(provider_calls->load() == 1); + CHECK(publication_attempts->load() == 2); + REQUIRE(worker_task_status == std::future_status::ready); + REQUIRE(worker_completion_status == std::future_status::ready); + CHECK(!worker_exception); + CHECK_FALSE(publication_accepted); + REQUIRE(connection_thread_status == std::future_status::ready); + REQUIRE(provider_thread_status == std::future_status::ready); + REQUIRE(completion_thread_status == std::future_status::ready); + CHECK(provider_thread != connection_thread); + CHECK(completion_thread_id == connection_thread); + REQUIRE(transfer_lifetime_status == std::future_status::ready); + CHECK(transfer_lifetime.expired()); + CHECK(connection_destroyed_status == std::future_status::ready); +} // async_chunked_response_recovers_abort_after_foreign_thread_publication_failure + + +TEST_CASE("async_chunked_response_contains_abort_publication_failure_until_shutdown") { + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto publication_attempts = std::make_shared>(0); + auto transfer_lifetime_promise = std::make_shared>>(); + auto transfer_lifetime_result = transfer_lifetime_promise->get_future(); + auto worker_task_promise = std::make_shared>>(); + auto worker_task_result = worker_task_promise->get_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + ScopedAsyncChunkPublicationTestHook publication_hook([publication_attempts] { + publication_attempts->fetch_add(1); + throw std::runtime_error("forced asynchronous chunk publication failure"); + }); + + CROW_ROUTE(app, "/async-abort-publication-failure") + ([provider_calls, transfer_lifetime_promise, worker_task_promise, completion_observation](const crow::request&, + crow::response& res) { + auto transfer_lifetime = std::make_shared(0); + transfer_lifetime_promise->set_value(std::weak_ptr(transfer_lifetime)); + res.set_async_chunked_content_provider( + [provider_calls, transfer_lifetime, worker_task_promise]( + crow::response::async_chunk_completion_t complete) { + static_cast(transfer_lifetime); + provider_calls->fetch_add(1); + worker_task_promise->set_value( + std::async(std::launch::async, [complete = std::move(complete)]() mutable { + return complete(crow::chunk_result::done, "discarded"); + })); + }, + "text/plain"); + res.set_chunked_completion_handler([completion_observation, transfer_lifetime](bool clean) { + static_cast(transfer_lifetime); + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using PublicationFailureServer = crow::Server; + PublicationFailureServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /async-abort-publication-failure HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + const auto worker_task_status = worker_task_result.wait_for(std::chrono::seconds(5)); + std::future worker_task; + if (worker_task_status == std::future_status::ready) + worker_task = worker_task_result.get(); + const auto worker_completion_status + = worker_task.valid() ? worker_task.wait_for(std::chrono::seconds(1)) : std::future_status::deferred; + std::exception_ptr worker_exception; + bool publication_accepted = true; + if (worker_completion_status == std::future_status::ready) { + try { + publication_accepted = worker_task.get(); + } catch (...) { + worker_exception = std::current_exception(); + } + } + const auto completion_status_before_shutdown = completion_result.wait_for(std::chrono::milliseconds(100)); + const auto transfer_lifetime_status = transfer_lifetime_result.wait_for(std::chrono::seconds(1)); + std::weak_ptr transfer_lifetime; + if (transfer_lifetime_status == std::future_status::ready) + transfer_lifetime = transfer_lifetime_result.get(); + const bool retained_before_shutdown = !transfer_lifetime.expired(); + + server_shutdown.shutdown(); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(1)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.close(close_error); + + REQUIRE(worker_task_status == std::future_status::ready); + REQUIRE(worker_completion_status == std::future_status::ready); + CHECK(!worker_exception); + CHECK_FALSE(publication_accepted); + CHECK(publication_attempts->load() == 2); + CHECK(completion_status_before_shutdown == std::future_status::timeout); + REQUIRE(transfer_lifetime_status == std::future_status::ready); + CHECK(retained_before_shutdown); + REQUIRE(connection_closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).find("discarded") == std::string::npos); + CHECK(response.substr(header_end + 4).find("0\r\n\r\n") == std::string::npos); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); + CHECK(provider_calls->load() == 1); + CHECK(transfer_lifetime.expired()); +} // async_chunked_response_contains_abort_publication_failure_until_shutdown + + +TEST_CASE("async_chunked_response_ignores_duplicate_request_completion") { + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto first_result_accepted = std::make_shared>(-1); + auto duplicate_accepted = std::make_shared>(-1); + auto final_result_accepted = std::make_shared>(-1); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + + CROW_ROUTE(app, "/duplicate-async-completion") + ([provider_calls, + first_result_accepted, + duplicate_accepted, + final_result_accepted, + completion_observation](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls, first_result_accepted, duplicate_accepted, final_result_accepted]( + crow::response::async_chunk_completion_t complete) { + const std::size_t call = provider_calls->fetch_add(1); + if (call == 0) + { + first_result_accepted->store(complete(crow::chunk_result::more, "")); + duplicate_accepted->store(complete(crow::chunk_result::done, "duplicate")); + } + else if (call == 1) + { + final_result_accepted->store(complete(crow::chunk_result::done, "final")); + } + else + { + complete(crow::chunk_result::abort, ""); + } + }, + "text/plain"); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { completion_observation->record(clean); }); + res.end(); + }); + + auto server_task = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + BoundedServerShutdown server_shutdown(server_task, [&app] { app.stop(); }); + REQUIRE(app.wait_for_server_start() == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /duplicate-async-completion HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + const auto has_chunk_terminator = [](const std::string& response) { + return response.size() >= 5 && response.compare(response.size() - 5, 5, "0\r\n\r\n") == 0; + }; + std::string response; + const bool response_complete + = receive_with_deadline(client, response, std::chrono::seconds(5), has_chunk_terminator); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : false; + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(response_complete); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4) == "5\r\nfinal\r\n0\r\n\r\n"); + CHECK(provider_calls->load() == 2); + CHECK(first_result_accepted->load() == 1); + CHECK(duplicate_accepted->load() == 0); + CHECK(final_result_accepted->load() == 1); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == true); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_ignores_duplicate_request_completion + + +TEST_CASE("async_chunked_response_discards_late_completion_after_peer_close") { + SimpleApp app; + + auto provider_started_promise = std::make_shared>(); + auto provider_started = provider_started_promise->get_future(); + auto transfer_lifetime_promise = std::make_shared>>(); + auto transfer_lifetime_result = transfer_lifetime_promise->get_future(); + auto delayed_completion = std::make_shared(); + auto delayed_completion_mutex = std::make_shared(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + auto connection_destroyed = socket_context.started_connection_destroyed_future(); + auto waiting_read_started = socket_context.observed_read_started_future(); + + CROW_ROUTE(app, "/late-async-completion") + ([provider_started_promise, + transfer_lifetime_promise, + delayed_completion, + delayed_completion_mutex, + completion_observation, + &socket_context](const crow::request&, crow::response& res) { + auto transfer_lifetime = std::make_shared(0); + transfer_lifetime_promise->set_value(std::weak_ptr(transfer_lifetime)); + res.set_async_chunked_content_provider( + [provider_started_promise, + delayed_completion, + delayed_completion_mutex, + transfer_lifetime, + &socket_context](crow::response::async_chunk_completion_t complete) { + static_cast(transfer_lifetime); + socket_context.observe_next_read(); + { + std::lock_guard lock(*delayed_completion_mutex); + *delayed_completion = std::move(complete); + } + provider_started_promise->set_value(); + }); + res.set_chunked_completion_handler([completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using LifetimeServer = crow::Server; + LifetimeServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /late-async-completion HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + const auto provider_status = provider_started.wait_for(std::chrono::seconds(5)); + std::weak_ptr transfer_lifetime; + if (provider_status == std::future_status::ready) + transfer_lifetime = transfer_lifetime_result.get(); + const auto read_start_status = waiting_read_started.wait_for(std::chrono::seconds(1)); + const bool retained_before_peer_close = !transfer_lifetime.expired(); + const std::size_t completion_calls_before_peer_close = completion_observation->calls(); + const auto connection_status_before_peer_close = connection_destroyed.wait_for(std::chrono::seconds(0)); + + asio_error_code close_error; + client.shutdown(asio::socket_base::shutdown_both, close_error); + client.close(close_error); + + const auto connection_destroyed_status = connection_destroyed.wait_for(std::chrono::seconds(1)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + const bool released_before_late_completion = transfer_lifetime.expired(); + + crow::response::async_chunk_completion_t complete; + { + std::lock_guard lock(*delayed_completion_mutex); + complete = std::move(*delayed_completion); + } + bool late_result_accepted = true; + bool source_continued = false; + if (complete) + { + late_result_accepted = complete(crow::chunk_result::more, "late"); + if (late_result_accepted) + source_continued = true; + } + + server_shutdown.shutdown(); + + REQUIRE(provider_status == std::future_status::ready); + REQUIRE(read_start_status == std::future_status::ready); + CHECK(retained_before_peer_close); + CHECK(completion_calls_before_peer_close == 0); + CHECK(connection_status_before_peer_close == std::future_status::timeout); + REQUIRE(connection_destroyed_status == std::future_status::ready); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(released_before_late_completion); + CHECK(transfer_lifetime.expired()); + CHECK(completion_observation->calls() == 1); + CHECK_FALSE(late_result_accepted); + CHECK_FALSE(source_continued); +} // async_chunked_response_discards_late_completion_after_peer_close + + +TEST_CASE("async_chunked_response_shutdown_releases_never_completing_provider") { + SimpleApp app; + + auto provider_started_promise = std::make_shared>(); + auto provider_started = provider_started_promise->get_future(); + auto transfer_lifetime_promise = std::make_shared>>(); + auto transfer_lifetime_result = transfer_lifetime_promise->get_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + + CROW_ROUTE(app, "/never-completing-async-chunk") + ([provider_started_promise, transfer_lifetime_promise, completion_observation](const crow::request&, + crow::response& res) { + auto transfer_lifetime = std::make_shared(0); + transfer_lifetime_promise->set_value(std::weak_ptr(transfer_lifetime)); + res.set_async_chunked_content_provider( + [provider_started_promise, transfer_lifetime](crow::response::async_chunk_completion_t complete) { + static_cast(complete); + static_cast(transfer_lifetime); + provider_started_promise->set_value(); + }); + res.set_chunked_completion_handler([completion_observation, transfer_lifetime](bool clean) { + static_cast(transfer_lifetime); + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using LifetimeServer = crow::Server; + LifetimeServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /never-completing-async-chunk HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + const auto provider_status = provider_started.wait_for(std::chrono::seconds(5)); + std::weak_ptr transfer_lifetime; + if (provider_status == std::future_status::ready) + transfer_lifetime = transfer_lifetime_result.get(); + const bool retained_before_shutdown = !transfer_lifetime.expired(); + const std::size_t completion_calls_before_shutdown = completion_observation->calls(); + + server_shutdown.shutdown(); + asio_error_code close_error; + client.close(close_error); + + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + REQUIRE(provider_status == std::future_status::ready); + CHECK(retained_before_shutdown); + CHECK(completion_calls_before_shutdown == 0); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(transfer_lifetime.expired()); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_shutdown_releases_never_completing_provider + + +TEST_CASE("async_chunked_response_shutdown_discards_queued_completion") { + SimpleApp app; + + auto provider_stopped_context_promise = std::make_shared>(); + auto provider_stopped_context = provider_stopped_context_promise->get_future(); + auto transfer_lifetime_promise = std::make_shared>>(); + auto transfer_lifetime_result = transfer_lifetime_promise->get_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + + CROW_ROUTE(app, "/queued-async-completion") + ([provider_stopped_context_promise, transfer_lifetime_promise, completion_observation](const crow::request& req, + crow::response& res) { + auto transfer_lifetime = std::make_shared(0); + transfer_lifetime_promise->set_value(std::weak_ptr(transfer_lifetime)); + auto* connection_io_context = req.io_context; + res.set_async_chunked_content_provider( + [provider_stopped_context_promise, transfer_lifetime, connection_io_context]( + crow::response::async_chunk_completion_t complete) { + static_cast(transfer_lifetime); + complete(crow::chunk_result::done, "queued"); + connection_io_context->stop(); + provider_stopped_context_promise->set_value(); + }); + res.set_chunked_completion_handler([completion_observation, transfer_lifetime](bool clean) { + static_cast(transfer_lifetime); + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using LifetimeServer = crow::Server; + LifetimeServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /queued-async-completion HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + const auto provider_status = provider_stopped_context.wait_for(std::chrono::seconds(5)); + std::weak_ptr transfer_lifetime; + if (provider_status == std::future_status::ready) + transfer_lifetime = transfer_lifetime_result.get(); + + server_shutdown.shutdown(); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(1)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.close(close_error); + + REQUIRE(provider_status == std::future_status::ready); + REQUIRE(connection_closed); + CHECK(response.find("queued") == std::string::npos); + CHECK(response.find("0\r\n\r\n") == std::string::npos); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(transfer_lifetime.expired()); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_shutdown_discards_queued_completion + + +TEST_CASE("async_chunked_response_server_stop_finishes_paused_write_on_worker") { + SimpleApp app; + + auto worker_thread_promise = std::make_shared>(); + auto worker_thread_result = worker_thread_promise->get_future(); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + auto completion_thread = completion_observation->first_thread(); + PausingSocketContext socket_context; + auto pending_write = socket_context.pending_write_future(); + + CROW_ROUTE(app, "/paused-async-chunk-during-stop") + ([worker_thread_promise, completion_observation, &socket_context](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [worker_thread_promise, &socket_context](crow::response::async_chunk_completion_t complete) { + worker_thread_promise->set_value(std::this_thread::get_id()); + socket_context.pause_next_write(); + complete(crow::chunk_result::done, "paused"); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { completion_observation->record(clean); }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using PausedWriteServer = crow::Server; + PausedWriteServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /paused-async-chunk-during-stop HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + const auto pending_write_status = pending_write.wait_for(std::chrono::seconds(5)); + const auto worker_thread_status = worker_thread_result.wait_for(std::chrono::seconds(1)); + const auto worker_thread + = worker_thread_status == std::future_status::ready ? worker_thread_result.get() : std::thread::id{}; + + server_shutdown.shutdown(); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(1)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + const auto completion_thread_status = completion_thread.wait_for(std::chrono::seconds(1)); + const auto observed_completion_thread + = completion_thread_status == std::future_status::ready ? completion_thread.get() : std::thread::id{}; + + socket_context.discard_pending_write(); + asio_error_code close_error; + client.close(close_error); + + REQUIRE(pending_write_status == std::future_status::ready); + REQUIRE(worker_thread_status == std::future_status::ready); + REQUIRE(connection_closed); + REQUIRE(completion_status == std::future_status::ready); + REQUIRE(completion_thread_status == std::future_status::ready); + CHECK(clean == false); + CHECK(observed_completion_thread == worker_thread); + CHECK(completion_observation->calls() == 1); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).find("0\r\n\r\n") == std::string::npos); +} // async_chunked_response_server_stop_finishes_paused_write_on_worker + + +TEST_CASE("async_chunked_response_header_write_error_completes_once_unclean") +{ + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + auto connection_destroyed = socket_context.started_connection_destroyed_future(); + + CROW_ROUTE(app, "/failing-async-chunk-header") + ([provider_calls, completion_observation, &socket_context](const crow::request&, crow::response& res) { + socket_context.fail_next_write(); + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t complete) { + provider_calls->fetch_add(1); + complete(crow::chunk_result::done, "unwritten"); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using HeaderWriteErrorServer = crow::Server; + HeaderWriteErrorServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /failing-async-chunk-header HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.close(close_error); + + // A failed header write must not stay alive on an armed write deadline. + CHECK(connection_destroyed.wait_for(std::chrono::seconds(2)) == std::future_status::ready); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + CHECK(response.find("0\r\n\r\n") == std::string::npos); + CHECK(provider_calls->load() == 0); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_header_write_error_completes_once_unclean + + +TEST_CASE("skipped_chunk_provider_header_write_error_completes_once_unclean") +{ + SimpleApp app; + auto provider_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + + CROW_ROUTE(app, "/failing-bodyless-header") + ([provider_calls, completion_observation, &socket_context](const crow::request&, crow::response& res) { + socket_context.fail_next_write(); + res.code = 204; + res.set_async_chunked_content_provider( + [provider_calls](crow::response::async_chunk_completion_t) { + provider_calls->fetch_add(1); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using HeaderWriteErrorServer = crow::Server; + HeaderWriteErrorServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /failing-bodyless-header HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string response; + REQUIRE(receive_until_closed_with_deadline(client.socket(), response, std::chrono::seconds(5))); + REQUIRE(completion_result.wait_for(std::chrono::seconds(1)) == std::future_status::ready); + CHECK(completion_result.get() == false); + CHECK(completion_observation->calls() == 1); + CHECK(provider_calls->load() == 0); + server_shutdown.shutdown(); +} // skipped_chunk_provider_header_write_error_completes_once_unclean + + +TEST_CASE("async_chunked_response_terminator_write_error_completes_once_unclean") +{ + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + + CROW_ROUTE(app, "/failing-async-chunk-terminator") + ([provider_calls, completion_observation, &socket_context](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls, &socket_context](crow::response::async_chunk_completion_t complete) { + const auto call = provider_calls->fetch_add(1); + if (call == 0) + { + complete(crow::chunk_result::more, "written"); + return; + } + + socket_context.fail_next_write(); + complete(crow::chunk_result::done, ""); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { + completion_observation->record(clean); + }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using TerminatorWriteErrorServer = crow::Server; + TerminatorWriteErrorServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { + server.run(); + }); + BoundedServerShutdown server_shutdown(server_task, [&server] { + server.stop(); + }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /failing-async-chunk-terminator HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.close(close_error); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4) == "7\r\nwritten\r\n"); + CHECK(response.find("0\r\n\r\n") == std::string::npos); + CHECK(provider_calls->load() == 2); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_terminator_write_error_completes_once_unclean + + +TEST_CASE("async_chunked_response_write_error_completes_once_unclean") { + SimpleApp app; + + auto provider_calls = std::make_shared>(0); + auto completion_observation = std::make_shared(); + auto completion_result = completion_observation->first_result(); + PausingSocketContext socket_context; + auto connection_destroyed = socket_context.started_connection_destroyed_future(); + + CROW_ROUTE(app, "/failing-async-chunk-write") + ([provider_calls, completion_observation, &socket_context](const crow::request&, crow::response& res) { + res.set_async_chunked_content_provider( + [provider_calls, &socket_context](crow::response::async_chunk_completion_t complete) { + provider_calls->fetch_add(1); + socket_context.fail_next_write(); + complete(crow::chunk_result::done, "unwritten"); + }); + res.set_chunked_completion_handler( + [completion_observation](bool clean) { completion_observation->record(clean); }); + res.end(); + }); + + app.validate(); + std::tuple<> middlewares; + using WriteErrorServer = crow::Server; + WriteErrorServer server(&app, + asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451), + "Crow/Test", + &middlewares, + 2, + 5, + &socket_context); + auto server_task = std::async(std::launch::async, [&server] { server.run(); }); + BoundedServerShutdown server_shutdown(server_task, [&server] { server.stop(); }); + REQUIRE(server.wait_for_start(std::chrono::steady_clock::now() + std::chrono::seconds(3)) + == std::cv_status::no_timeout); + + asio::io_context io_context; + asio::ip::tcp::socket client(io_context); + client.connect(asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); + const std::string request = "GET /failing-async-chunk-write HTTP/1.1\r\nHost: localhost\r\n\r\n"; + asio::write(client, asio::buffer(request)); + + std::string response; + const bool connection_closed = receive_until_closed_with_deadline(client, response, std::chrono::seconds(5)); + const auto completion_status = completion_result.wait_for(std::chrono::seconds(1)); + const bool clean = completion_status == std::future_status::ready ? completion_result.get() : true; + + asio_error_code close_error; + client.close(close_error); + + // A failed chunk write must not stay alive on an armed write deadline. + CHECK(connection_destroyed.wait_for(std::chrono::seconds(2)) == std::future_status::ready); + server_shutdown.shutdown(); + + REQUIRE(connection_closed); + const auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).empty()); + CHECK(provider_calls->load() == 1); + REQUIRE(completion_status == std::future_status::ready); + CHECK(clean == false); + CHECK(completion_observation->calls() == 1); +} // async_chunked_response_write_error_completes_once_unclean + + +TEST_CASE("chunked_response_no_data") { + SimpleApp app; + + CROW_ROUTE(app, "/empty") + ([](const crow::request&, crow::response& res) { + int calls = 0; + res.set_chunked_content_provider([calls](std::string& chunk) mutable -> bool { + chunk.clear(); + return ++calls < 3; // three calls producing nothing at all + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /empty HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + + app.stop(); +} // chunked_response_no_data + + +TEST_CASE("chunked_response_large_body") +{ + SimpleApp app; + + const size_t chunk_count = 64; + const size_t chunk_size = 1024; + + CROW_ROUTE(app, "/large") + ([chunk_count, chunk_size](const crow::request&, crow::response& res) { + size_t remaining = chunk_count; + res.set_chunked_content_provider([remaining, chunk_size](std::string& chunk) mutable -> bool { + if (remaining == 0) + return false; + chunk.assign(chunk_size, 'x'); + --remaining; + return true; + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /large HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + // decode the chunked body: every frame is "\r\n\r\n", + // the terminating frame has size zero + auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + std::string chunked_body = response.substr(header_end + 4); + size_t seen = 0; + size_t total = 0; + std::string::size_type pos = 0; + while (true) + { + auto size_end = chunked_body.find("\r\n", pos); + REQUIRE(size_end != std::string::npos); + size_t size = std::stoul(chunked_body.substr(pos, size_end - pos), nullptr, 16); + if (size == 0) + break; + ++seen; + total += size; + pos = size_end + 2 + size + 2; // past the size line, the data and its trailing CRLF + REQUIRE(pos <= chunked_body.size()); + } + CHECK(seen == chunk_count); + CHECK(total == chunk_count * chunk_size); + + app.stop(); +} // chunked_response_large_body + + +TEST_CASE("chunked_response_head_request") +{ + SimpleApp app; + + auto completion_clean = std::make_shared>(); + + CROW_ROUTE(app, "/chunks").methods("GET"_method, "HEAD"_method)([completion_clean](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string& chunk) -> bool { + chunk = "body"; + return false; + }); + res.set_chunked_completion_handler([completion_clean](bool clean) { + completion_clean->set_value(clean); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + std::string response = HttpClient::request(LOCALHOST_ADDRESS, 45451, "HEAD /chunks HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + // Same header fields as a GET would produce: the body length is unknown, so + // "Transfer-Encoding: chunked" is announced and "Content-Length" is absent. + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + CHECK(response.find("Content-Length") == std::string::npos); + + // The body itself is skipped entirely. + auto header_end = response.find("\r\n\r\n"); + REQUIRE(header_end != std::string::npos); + CHECK(response.substr(header_end + 4).empty()); + CHECK(response.find("body") == std::string::npos); + + // The provider is never called, but the completion handler still runs (with + // clean == true): it stays the single release point for the source of the data. + auto completion = completion_clean->get_future(); + REQUIRE(completion.wait_for(std::chrono::seconds(5)) == std::future_status::ready); + CHECK(completion.get() == true); + + app.stop(); +} // chunked_response_head_request + + +TEST_CASE("chunked_response_abort") +{ + SimpleApp app; + + CROW_ROUTE(app, "/abort") + ([](const crow::request&, crow::response& res) { + int calls = 0; + res.set_chunked_content_provider( + [calls](std::string& chunk) mutable -> crow::chunk_result { + if (++calls < 3) + { + chunk = "part" + std::to_string(calls); + return crow::chunk_result::more; + } + return crow::chunk_result::abort; + }, + "text/plain"); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /abort HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + // The server closes the connection without the terminating frame, so reading + // past the truncated body eventually throws (end of file). + std::string response; + try + { + while (true) + response += client.receive(); + } + catch (const std::exception&) + { + } + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + // the body is truncated: the produced chunks are there, the terminating frame is not + auto body_start = response.find("\r\n\r\n"); + REQUIRE(body_start != std::string::npos); + std::string chunked_body = response.substr(body_start + 4); + CHECK(chunked_body.find("5\r\npart1\r\n") != std::string::npos); + CHECK(chunked_body.find("5\r\npart2\r\n") != std::string::npos); + CHECK(chunked_body.find("0\r\n\r\n") == std::string::npos); + + app.stop(); +} // chunked_response_abort + + +TEST_CASE("chunked_response_completion_handler") +{ + SimpleApp app; + + auto done_clean = std::make_shared>(); + auto abort_clean = std::make_shared>(); + + CROW_ROUTE(app, "/done") + ([done_clean](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string& chunk) { + chunk = "body"; + return crow::chunk_result::done; + }); + res.set_chunked_completion_handler([done_clean](bool clean) { + done_clean->set_value(clean); + }); + res.end(); + }); + + CROW_ROUTE(app, "/abort") + ([abort_clean](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string&) { + return crow::chunk_result::abort; + }); + res.set_chunked_completion_handler([abort_clean](bool clean) { + abort_clean->set_value(clean); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /done HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + } + CHECK(done_clean->get_future().get() == true); + + { + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /abort HTTP/1.1\r\nHost: localhost\r\n\r\n"); + try + { + while (true) + client.receive(); + } + catch (const std::exception&) + { + } + } + CHECK(abort_clean->get_future().get() == false); + + app.stop(); +} // chunked_response_completion_handler + + +TEST_CASE("chunked_response_throwing_completion_handler") +{ + SimpleApp app; + + CROW_ROUTE(app, "/throwing") + ([](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string& chunk) { + chunk = "body"; + return crow::chunk_result::done; + }); + res.set_chunked_completion_handler([](bool) { + throw std::runtime_error("completion failed"); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + // An exception from the completion handler must not skip the connection + // cleanup: the response is still delivered in full and the server survives. + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /throwing HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string response; + while (response.size() < 5 || response.compare(response.size() - 5, 5, "0\r\n\r\n") != 0) + response += client.receive(); + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + // The connection stays usable for the next request. + client.send("GET /throwing HTTP/1.1\r\nHost: localhost\r\n\r\n"); + std::string second; + while (second.size() < 5 || second.compare(second.size() - 5, 5, "0\r\n\r\n") != 0) + second += client.receive(); + CHECK(second.find("Transfer-Encoding: chunked") != std::string::npos); + + app.stop(); +} // chunked_response_throwing_completion_handler + + +TEST_CASE("chunked_provider_excludes_other_body_sources") +{ + // A response has exactly one body source; whichever is configured last wins. + + // A chunk provider discards a previously configured static file and string body. + { + response res; + res.set_static_file_info("tests/img/cat.jpg"); + res.body = "leftover"; + res.set_chunked_content_provider([](std::string&) { return crow::chunk_result::done; }); + + CHECK(res.is_chunked_type()); + CHECK(!res.is_static_type()); + CHECK(res.body.empty()); + CHECK(res.get_header_value("Content-Length").empty()); + CHECK(res.get_header_value("Transfer-Encoding") == "chunked"); + } + + // A static file discards a previously configured chunk provider and its framing header. + { + response res; + res.set_chunked_content_provider([](std::string&) { return crow::chunk_result::done; }); + res.set_static_file_info("tests/img/cat.jpg"); + + CHECK(!res.is_chunked_type()); + CHECK(res.is_static_type()); + CHECK(res.get_header_value("Transfer-Encoding").empty()); + CHECK(!res.get_header_value("Content-Length").empty()); + } +} // chunked_provider_excludes_other_body_sources + + +TEST_CASE("chunked_response_throwing_provider") +{ + SimpleApp app; + + auto throw_clean = std::make_shared>(); + + CROW_ROUTE(app, "/throw") + ([throw_clean](const crow::request&, crow::response& res) { + int calls = 0; + res.set_chunked_content_provider( + [calls](std::string& chunk) mutable -> bool { + if (++calls < 3) + { + chunk = "part" + std::to_string(calls); + return true; + } + throw std::runtime_error("provider failed"); + }, + "text/plain"); + res.set_chunked_completion_handler([throw_clean](bool clean) { + throw_clean->set_value(clean); + }); + res.end(); + }); + + auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); + app.wait_for_server_start(); + + HttpClient client(LOCALHOST_ADDRESS, 45451); + client.send("GET /throw HTTP/1.1\r\nHost: localhost\r\n\r\n"); + + // The exception is treated as an abort: the connection is closed without the + // terminating frame, so reading past the truncated body eventually throws. + std::string response; + try + { + while (true) + response += client.receive(); + } + catch (const std::exception&) + { + } + + CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); + + auto body_start = response.find("\r\n\r\n"); + REQUIRE(body_start != std::string::npos); + std::string chunked_body = response.substr(body_start + 4); + CHECK(chunked_body.find("5\r\npart1\r\n") != std::string::npos); + CHECK(chunked_body.find("5\r\npart2\r\n") != std::string::npos); + CHECK(chunked_body.find("0\r\n\r\n") == std::string::npos); + + CHECK(throw_clean->get_future().get() == false); + + app.stop(); +} // chunked_response_throwing_provider diff --git a/tests/test_common.h b/tests/test_common.h new file mode 100644 index 0000000000..d365035d16 --- /dev/null +++ b/tests/test_common.h @@ -0,0 +1,169 @@ +#pragma once + +// Helpers shared between the unit test translation units. + +#include +#include +#include + +#include "crow.h" + +#ifdef CROW_USE_BOOST +namespace asio = boost::asio; +using asio_error_code = boost::system::error_code; +#else +using asio_error_code = asio::error_code; +#endif + +#define LOCALHOST_ADDRESS "127.0.0.1" + +/** simple http client class for making client requests */ +class HttpClient +{ +private: + asio::io_context ic{}; + asio::ip::tcp::socket c; + +public: + /** construct an instance by address and port */ + HttpClient(std::string const& address, uint16_t port): + c(ic) + { + c.connect(asio::ip::tcp::endpoint( asio::ip::make_address(address), + port)); + } + + /** sends a request string through the socket */ + void send(const std::string& msg) + { + c.send(asio::buffer(msg)); + } + + /** sends a request string through the socket */ + void send(const char* const msg, size_t msg_size) + { + c.send(asio::buffer(msg, msg_size)); + } + + + /** method shall be called after sending a request with send + * @returns the received response string */ + std::string receive() + { + char buf[2048]; + auto received = c.receive(asio::buffer(buf, sizeof(buf))); + std::string rval(buf, received); + return rval; + } + + asio::ip::tcp::socket& socket() + { + return c; + } + + /** static method for making a request + * @returns the received response string */ + static std::string request(const std::string& address, + uint16_t port, + const std::string& sendmsg) + { + HttpClient c(address, port); + c.send(sendmsg); + return c.receive(); + } +}; + +class BoundedServerShutdown { +public: + BoundedServerShutdown(std::future& server_task, std::function stop) + : server_task_(server_task) + , stop_(std::move(stop)) { + } + + ~BoundedServerShutdown() { + shutdown(); + } + + void shutdown() noexcept { + if (stopped_) + return; + + stopped_ = true; + try { + stop_(); + } catch (...) { + std::terminate(); + } + + if (server_task_.valid() && server_task_.wait_for(std::chrono::seconds(5)) != std::future_status::ready) + std::terminate(); + } + +private: + std::future& server_task_; + std::function stop_; + bool stopped_{false}; +}; + +template +bool receive_with_deadline(asio::ip::tcp::socket& socket, + std::string& response, + std::chrono::milliseconds timeout, + Predicate complete, + bool* peer_closed = nullptr) { + if (peer_closed) + *peer_closed = false; + if (complete(response)) + return true; + + auto& io_context = GET_IO_CONTEXT(socket); +#if (defined(CROW_USE_BOOST) && BOOST_VERSION >= 107000) || (ASIO_VERSION >= 101008) + io_context.restart(); +#else + io_context.reset(); +#endif + asio::steady_timer timer(io_context); + std::array buffer; + bool timed_out = false; + std::function read_next; + + read_next = [&] { + socket.async_read_some(asio::buffer(buffer), [&](const asio_error_code& ec, std::size_t received) { + if (!ec) { + response.append(buffer.data(), received); + if (complete(response)) { + timer.cancel(); + } else { + read_next(); + } + return; + } + + if (!timed_out && peer_closed) + *peer_closed = true; + timer.cancel(); + }); + }; + + timer.expires_after(timeout); + timer.async_wait([&](const asio_error_code& ec) { + if (ec) + return; + + timed_out = true; + asio_error_code cancel_error; + socket.cancel(cancel_error); + }); + read_next(); + io_context.run(); + + return complete(response); +} + +inline bool receive_until_closed_with_deadline(asio::ip::tcp::socket& socket, + std::string& response, + std::chrono::milliseconds timeout) { + bool peer_closed = false; + receive_with_deadline(socket, response, timeout, [](const std::string&) { return false; }, &peer_closed); + return peer_closed; +} diff --git a/tests/unit_tests/test_http_response.cpp b/tests/unit_tests/test_http_response.cpp index 6d67216af5..d9d1ad5fa7 100644 --- a/tests/unit_tests/test_http_response.cpp +++ b/tests/unit_tests/test_http_response.cpp @@ -2,6 +2,8 @@ #include "crow.h" +#include + using namespace crow; TEST_CASE("custom_content_types") @@ -37,6 +39,7 @@ TEST_CASE("simple_response") CHECK(100 == response(100).code); CHECK(200 == response("Hello there").code); CHECK(500 == response(500, "Internal Error?").code); + CHECK(505 == response(static_cast(status::HTTP_VERSION_NOT_SUPPORTED)).code); CHECK(100 == response(100, "xml", "").code); CHECK("text/xml" == response(100, "xml", "").get_header_value("Content-Type")); @@ -44,4 +47,203 @@ TEST_CASE("simple_response") CHECK("text/html" == response(200, "html", "").get_header_value("Content-Type")); CHECK(500 == response(500, "html", "Internal Error?").code); CHECK("text/css" == response(500, "css", "Internal Error?").get_header_value("Content-Type")); -} \ No newline at end of file +} + +TEST_CASE("clear_restores_ordinary_body_framing_after_chunk_provider") +{ + SECTION("synchronous provider") + { + response res; + res.set_chunked_content_provider([](std::string&) { + return response::chunk_result::done; + }); + + REQUIRE(res.manual_length_header); + res.clear(); + res.write("sync-body"); + res.end(); + + CHECK_FALSE(res.manual_length_header); + CHECK_FALSE(res.is_chunked_type()); + CHECK(res.get_header_value("Transfer-Encoding").empty()); + CHECK(res.body == "sync-body"); + CHECK(res.is_completed()); + } + + SECTION("asynchronous provider") + { + response res; + res.set_async_chunked_content_provider( + [](response::async_chunk_completion_t complete) { + complete(response::chunk_result::done, ""); + }); + + REQUIRE(res.manual_length_header); + res.clear(); + res.write("async-body"); + res.end(); + + CHECK_FALSE(res.manual_length_header); + CHECK_FALSE(res.is_chunked_type()); + CHECK(res.get_header_value("Transfer-Encoding").empty()); + CHECK(res.body == "async-body"); + CHECK(res.is_completed()); + } +} + +TEST_CASE("direct_head_response_completes_chunk_provider_once_clean") +{ + SECTION("synchronous provider") + { + response res; + std::size_t provider_calls = 0; + std::size_t completion_calls = 0; + bool completion_clean = false; + res.skip_body = true; + res.set_chunked_content_provider([&provider_calls](std::string&) { + ++provider_calls; + return response::chunk_result::done; + }); + res.set_chunked_completion_handler([&](bool clean) { + ++completion_calls; + completion_clean = clean; + }); + + res.end(); + res.end(); + + CHECK(provider_calls == 0); + CHECK(completion_calls == 1); + CHECK(completion_clean); + } + + SECTION("asynchronous provider") + { + response res; + std::size_t provider_calls = 0; + std::size_t completion_calls = 0; + bool completion_clean = false; + res.skip_body = true; + res.set_async_chunked_content_provider( + [&provider_calls](response::async_chunk_completion_t complete) { + ++provider_calls; + complete(response::chunk_result::done, "unused"); + }); + res.set_chunked_completion_handler([&](bool clean) { + ++completion_calls; + completion_clean = clean; + }); + + res.end(); + res.end(); + + CHECK(provider_calls == 0); + CHECK(completion_calls == 1); + CHECK(completion_clean); + } +} + +TEST_CASE("async_chunked_provider_owns_the_response_body_source") { + SECTION("installation replaces static file and string body") { + response res; + res.set_static_file_info("tests/img/cat.jpg"); + res.body = "leftover"; + + response::async_chunk_provider_t provider + = [](response::async_chunk_completion_t complete) { complete(response::chunk_result::done, ""); }; + res.set_async_chunked_content_provider(std::move(provider), "text/plain"); + + CHECK(res.is_chunked_type()); + CHECK(!res.is_static_type()); + CHECK(res.body.empty()); + CHECK(res.get_header_value("Content-Length").empty()); + CHECK(res.get_header_value("Transfer-Encoding") == "chunked"); + CHECK(res.get_header_value("Content-Type") == "text/plain"); + } + + SECTION("installation releases synchronous provider") { + response res; + auto synchronous_marker = std::make_shared(1); + std::weak_ptr synchronous_marker_observer = synchronous_marker; + res.set_chunked_content_provider([synchronous_marker](std::string&) { return response::chunk_result::done; }); + synchronous_marker.reset(); + + res.set_async_chunked_content_provider( + [](response::async_chunk_completion_t complete) { complete(response::chunk_result::done, ""); }); + + CHECK(synchronous_marker_observer.expired()); + } + + SECTION("an empty callable still selects the asynchronous body source") + { + response source; + response::async_chunk_provider_t empty_provider; + + source.set_async_chunked_content_provider(std::move(empty_provider)); + + CHECK(source.is_chunked_type()); + CHECK(source.get_header_value("Transfer-Encoding") == "chunked"); + + response destination(std::move(source)); + + CHECK(destination.is_chunked_type()); + CHECK(!source.is_chunked_type()); + + destination.clear(); + + CHECK(!destination.is_chunked_type()); + } + + SECTION("static file releases asynchronous provider") { + response res; + auto asynchronous_marker = std::make_shared(1); + std::weak_ptr asynchronous_marker_observer = asynchronous_marker; + res.set_async_chunked_content_provider([asynchronous_marker](response::async_chunk_completion_t complete) { + complete(response::chunk_result::done, ""); + }); + asynchronous_marker.reset(); + + res.set_static_file_info("tests/img/cat.jpg"); + + CHECK(asynchronous_marker_observer.expired()); + CHECK(!res.is_chunked_type()); + CHECK(res.is_static_type()); + CHECK(res.get_header_value("Transfer-Encoding").empty()); + } + + SECTION("synchronous provider releases asynchronous provider") { + response res; + auto asynchronous_marker = std::make_shared(1); + std::weak_ptr asynchronous_marker_observer = asynchronous_marker; + res.set_async_chunked_content_provider([asynchronous_marker](response::async_chunk_completion_t complete) { + complete(response::chunk_result::done, ""); + }); + asynchronous_marker.reset(); + + res.set_chunked_content_provider([](std::string&) { return response::chunk_result::done; }); + + CHECK(asynchronous_marker_observer.expired()); + CHECK(res.is_chunked_type()); + } + + SECTION("clear releases moved asynchronous provider") { + response source; + auto asynchronous_marker = std::make_shared(1); + std::weak_ptr asynchronous_marker_observer = asynchronous_marker; + source.set_async_chunked_content_provider([asynchronous_marker](response::async_chunk_completion_t complete) { + complete(response::chunk_result::done, ""); + }); + asynchronous_marker.reset(); + + response destination(std::move(source)); + + CHECK(destination.is_chunked_type()); + CHECK(!asynchronous_marker_observer.expired()); + + destination.clear(); + + CHECK(!destination.is_chunked_type()); + CHECK(asynchronous_marker_observer.expired()); + CHECK(destination.get_header_value("Transfer-Encoding").empty()); + } +} diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 1e60242bd2..2b1695f2b3 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2,12 +2,20 @@ #define CROW_LOG_LEVEL 0 #include +#include +#include +#include +#include #include +#include +#include #include +#include #include #include #include #include +#include #include "catch2/catch_all.hpp" #include "crow.h" @@ -15,6 +23,8 @@ #include "crow/middlewares/cors.h" #include "crow/middlewares/session.h" +#include "test_common.h" + using namespace std; using namespace crow; @@ -25,64 +35,10 @@ using asio_error_code = boost::system::error_code; using asio_error_code = asio::error_code; #endif -#define LOCALHOST_ADDRESS "127.0.0.1" - -/** simple http client class for making client requests */ -class HttpClient -{ -private: - asio::io_context ic{}; - asio::ip::tcp::socket c; - -public: - /** construct an instance by address and port */ - HttpClient(std::string const& address, uint16_t port): - c(ic) - { - c.connect(asio::ip::tcp::endpoint( asio::ip::make_address(address), - port)); - } - - /** sends a request string through the socket */ - void send(const std::string& msg) - { - c.send(asio::buffer(msg)); - } - /** sends a request string through the socket */ - void send(const char* const msg, size_t msg_size) - { - c.send(asio::buffer(msg, msg_size)); - } - - - /** method shall be called after sending a request with send - * @returns the received response string */ - std::string receive() - { - char buf[2048]; - auto received = c.receive(asio::buffer(buf, sizeof(buf))); - std::string rval(buf, received); - return rval; - } - - /** static method for making a request - * @returns the received response string */ - static std::string request(const std::string& address, - uint16_t port, - const std::string& sendmsg) - { - HttpClient c(address, port); - c.send(sendmsg); - return c.receive(); - } -}; - -bool is_tcp_nodelay_enabled_for_connection_after_apply(const crow::detail::socket::tcp_socket_options& options) -{ +bool is_tcp_nodelay_enabled_for_connection_after_apply(const crow::detail::socket::tcp_socket_options& options) { asio::io_context io_context; - asio::ip::tcp::acceptor acceptor(io_context, - asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 0)); + asio::ip::tcp::acceptor acceptor(io_context, asio::ip::tcp::endpoint(asio::ip::make_address(LOCALHOST_ADDRESS), 0)); asio::ip::tcp::socket client_socket(io_context); client_socket.connect(acceptor.local_endpoint()); @@ -1992,83 +1948,6 @@ TEST_CASE("send_file") } } // send_file -TEST_CASE("stream_response") -{ - SimpleApp app; - - - const std::string keyword_ = "hello"; - const size_t repetitions = 250000; - const size_t key_response_size = keyword_.length() * repetitions; - - std::string key_response; - - for (size_t i = 0; i < repetitions; i++) - key_response += keyword_; - - CROW_ROUTE(app, "/test") - ([&key_response](const crow::request&, crow::response& res) { - res.body = key_response; - res.end(); - }); - - app.validate(); - - // running the test on a separate thread to allow the client to sleep - std::thread runTest([&app, &key_response, key_response_size, keyword_]() { - auto _ = app.bindaddr(LOCALHOST_ADDRESS).port(45451).run_async(); - app.wait_for_server_start(); - asio::io_context io_context; - std::string sendmsg; - - //Total bytes received - unsigned int received = 0; - sendmsg = "GET /test HTTP/1.0\r\n\r\n"; - { - asio::streambuf b; - - asio::ip::tcp::socket c(io_context); - c.connect(asio::ip::tcp::endpoint( - asio::ip::make_address(LOCALHOST_ADDRESS), 45451)); - c.send(asio::buffer(sendmsg)); - - // consuming the headers, since we don't need those for the test - static char buf[2048]; - size_t received_headers_bytes = 0; - - // Magic number is 102. It's the size of the headers, which is at - // least how much we need to read. Since the header size may change - // and break the test, we read twice as much as the header and - // search in the received data for the first occurrence of keyword_. - const size_t headers_bytes_and_some = 102 * 2; - while (received_headers_bytes < headers_bytes_and_some) - received_headers_bytes += c.receive(asio::buffer(buf + received_headers_bytes, - sizeof(buf) / sizeof(buf[0]) - received_headers_bytes)); - - const std::string::size_type header_end_pos = std::string(buf, received_headers_bytes).find(keyword_); - received += received_headers_bytes - header_end_pos; // add any extra that might have been received to the proper received count - - while (received < key_response_size) - { - asio::streambuf::mutable_buffers_type bufs = b.prepare(16384); - - size_t n(0); - n = c.receive(bufs); - b.commit(n); - received += n; - - std::istream istream(&b); - std::string s; - istream >> s; - - CHECK(key_response.substr(received - n, n) == s); - } - } - app.stop(); - }); - runTest.join(); -} // stream_response - #ifdef CROW_ENABLE_COMPRESSION TEST_CASE("zlib_compression") {