From 5835259aa5cc977bb30558173e281d77fc0c2148 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Wed, 5 Aug 2026 20:33:21 +0200 Subject: [PATCH 01/16] Add chunked content provider to response --- include/crow/http_response.h | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 71a9e0c7a9..7977b27132 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -56,6 +56,14 @@ 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. Leaving the + /// string empty is allowed and sends no chunk. + using chunk_provider_t = std::function; + /// Set the value of an existing header in the response. void set_header(std::string key, std::string value) { @@ -183,6 +191,7 @@ namespace crow headers = std::move(r.headers); completed_ = r.completed_; file_info = std::move(r.file_info); + chunk_provider_ = std::move(r.chunk_provider_); return *this; } @@ -199,6 +208,7 @@ namespace crow headers.clear(); completed_ = false; file_info = static_file_info{}; + chunk_provider_ = nullptr; } /// Return a "Temporary Redirect" response. @@ -254,6 +264,7 @@ namespace crow completed_ = true; if (skip_body) { + chunk_provider_ = nullptr; set_header("Content-Length", std::to_string(body.size())); body = ""; manual_length_header = true; @@ -286,6 +297,29 @@ namespace crow return file_info.path.size(); } + /// Check whether the response body is produced by a chunk provider. + bool is_chunked_type() const + { + return static_cast(chunk_provider_); + } + + /// 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 thread while the response is being written. + void set_chunked_content_provider(chunk_provider_t provider, std::string content_type = "") + { + chunk_provider_ = std::move(provider); + manual_length_header = true; + set_header("Transfer-Encoding", "chunked"); + if (!content_type.empty()) + { + set_header("Content-Type", std::move(content_type)); + } + } + /// This constains metadata (coming from the `stat` command) related to any static files associated with this response. /// @@ -460,5 +494,6 @@ namespace crow std::function complete_request_handler_; std::function is_alive_helper_; static_file_info file_info; + chunk_provider_t chunk_provider_; }; } // namespace crow From 5d6c835c8a42f779b870421ad1710224821c8624 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Wed, 5 Aug 2026 20:33:51 +0200 Subject: [PATCH 02/16] Write chunked responses in the connection --- include/crow/http_connection.h | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index 2ebf72a956..b7bd5202c6 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -275,6 +275,10 @@ namespace crow { do_write_static(); } + else if (res.is_chunked_type()) + { + do_write_chunked(); + } else { do_write_general(); @@ -331,6 +335,91 @@ namespace crow parser_.clear(); } + /// 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; + } + + void do_write_chunked() + { + error_code ec; + 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."; + } + + // Producing the body may take arbitrarily long, so the connection must not be + // closed by the deadline while chunks are still on their way. + cancel_deadline_timer(); + + // do_write_sync() clears the response on every write, so the provider has to be + // taken out of it before the loop starts. + auto provider = std::move(res.chunk_provider_); + res.chunk_provider_ = nullptr; + + std::string chunk; + std::string chunk_header; + std::vector buffers{3}; + bool more = static_cast(provider); + while (more && !ec) + { + chunk.clear(); + more = provider(chunk); + if (chunk.empty()) + { + continue; + } + + chunk_header = chunk_size_to_hex(chunk.size()); + chunk_header += crlf; + buffers[0] = asio::const_buffer(chunk_header.data(), chunk_header.size()); + buffers[1] = asio::const_buffer(chunk.data(), chunk.size()); + buffers[2] = asio::const_buffer(crlf.data(), crlf.size()); + ec = do_write_sync(buffers); + if (ec) + { + CROW_LOG_ERROR << ec << " - buffer write error happened while sending a chunk. Writing stopped premature."; + } + } + + if (!ec) + { + static const std::string last_chunk = "0\r\n\r\n"; + std::vector tail{1}; + tail[0] = asio::const_buffer(last_chunk.data(), last_chunk.size()); + ec = do_write_sync(tail); + if (ec) + { + CROW_LOG_ERROR << ec << " - buffer write error happened while sending the last chunk."; + } + } + + if (close_connection_) + { + adaptor_.shutdown_readwrite(); + adaptor_.close(); + CROW_LOG_DEBUG << this << " from write (chunked)"; + } + + res.end(); + res.clear(); + buffers_.clear(); + parser_.clear(); + } + void do_write_general() { error_code ec; From 5a52b503fd35a6b520c1d4c4fd5ee4c228f5a701 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Wed, 5 Aug 2026 20:41:17 +0200 Subject: [PATCH 03/16] Add tests for chunked responses --- include/crow/http_connection.h | 9 +++ tests/unittest.cpp | 133 +++++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index b7bd5202c6..76f42638eb 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -418,6 +418,15 @@ namespace crow res.clear(); buffers_.clear(); parser_.clear(); + + // The deadline was cancelled for the duration of the transfer, so a kept-alive + // connection has to be put back into reading state explicitly. + if (!close_connection_ && need_to_start_read_after_complete_) + { + need_to_start_read_after_complete_ = false; + start_deadline(); + do_read(); + } } void do_write_general() diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 1e60242bd2..6331b7f504 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2069,6 +2069,139 @@ TEST_CASE("stream_response") 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.0\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); + + app.stop(); +} // chunked_response + +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.0\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.0\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); + + // every chunk carries its size in hex, 1024 bytes being "400" + size_t seen = 0; + for (std::string::size_type pos = response.find("400\r\n"); pos != std::string::npos; + pos = response.find("400\r\n", pos + 1)) + ++seen; + CHECK(seen >= chunk_count); + + app.stop(); +} // chunked_response_large_body + +TEST_CASE("chunked_response_head_request") +{ + SimpleApp app; + + CROW_ROUTE(app, "/chunks").methods("GET"_method, "HEAD"_method)([](const crow::request&, crow::response& res) { + res.set_chunked_content_provider([](std::string& chunk) -> bool { + chunk = "body"; + return false; + }); + 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.0\r\n\r\n"); + + CHECK(response.find("body") == std::string::npos); + + app.stop(); +} // chunked_response_head_request + #ifdef CROW_ENABLE_COMPRESSION TEST_CASE("zlib_compression") { From a96ac1f53e6d0bcd6539fadaf020e0f3a847d054 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Wed, 5 Aug 2026 20:41:37 +0200 Subject: [PATCH 04/16] Document chunked responses --- docs/guides/streaming.md | 59 ++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 60 insertions(+) create mode 100644 docs/guides/streaming.md diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md new file mode 100644 index 0000000000..0a08197543 --- /dev/null +++ b/docs/guides/streaming.md @@ -0,0 +1,59 @@ +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). + +Call `#!cpp response.set_chunked_content_provider(, )` with a +callable that produces the body one piece at a time. Crow sets +`Transfer-Encoding: chunked`, omits `Content-Length`, and calls the provider +repeatedly while writing the response. + +## The provider + +```cpp +bool provider(std::string& chunk); +``` + +Fill `chunk` with the next piece of the body and return `#!cpp true` while more +data is coming, `#!cpp false` on the last invocation. Leaving `chunk` empty is +allowed and sends nothing, which is handy when the source of the data has +produced no bytes yet. + +### Example + +```cpp +auto app = crow::SimpleApp(); + +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(); +}); +``` + +## Notes + +!!! note + + The provider runs on the connection's thread while the response is being + written, so a provider that blocks keeps that thread busy for the whole + transfer. + +!!! note + + The connection deadline is cancelled for the duration of the transfer. + Without that, a body that takes longer to produce than the timeout would be + cut short by the connection being closed. + +!!! note + + A response to a `HEAD` request never calls the provider: the headers are sent + and the body is skipped. 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 From b7465afc69035c42248cdedd25ab46310bc9fee6 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 14:35:17 +0200 Subject: [PATCH 05/16] Add abort result and completion handler for chunked responses A chunk provider can now return chunk_result (more/done/abort) instead of bool: abort closes the connection without the terminating frame, so the client sees a truncated body. An optional completion handler reports whether the body was written cleanly. The bool provider overload keeps its exact behaviour by wrapping into the new one. --- docs/guides/streaming.md | 51 +++++++++++++++ include/crow/http_connection.h | 46 +++++++++++--- include/crow/http_response.h | 63 ++++++++++++++++++- tests/unittest.cpp | 111 +++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 12 deletions(-) diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md index 0a08197543..fe9487ba22 100644 --- a/docs/guides/streaming.md +++ b/docs/guides/streaming.md @@ -39,6 +39,57 @@ CROW_ROUTE(app, "/numbers") }); ``` +## Aborting the transfer + +A provider that discovers midway that the body cannot be finished (the source of +the data failed, for example) should not let the response end normally: without +`Content-Length`, the terminating frame is the only thing that tells the client +the body is complete. For this case the provider can return +`#!cpp crow::chunk_result` instead of `#!cpp bool`: + +```cpp +crow::chunk_result provider(std::string& chunk); +``` + +Return `#!cpp crow::chunk_result::more` while more data is coming, +`#!cpp crow::chunk_result::done` on the last invocation, or +`#!cpp crow::chunk_result::abort` to stop the transfer. On `abort` Crow closes +the connection without sending the terminating frame, so the client sees a +truncated body instead of a seemingly complete one. + +```cpp +CROW_ROUTE(app, "/file") +([](const crow::request&, crow::response& res) { + auto file = open_source_somehow(); + res.set_chunked_content_provider( + [file](std::string& chunk) mutable -> crow::chunk_result { + if (!file->read(chunk)) + return crow::chunk_result::abort; // reading failed: truncate the body + return chunk.empty() ? crow::chunk_result::done : crow::chunk_result::more; + }, + "application/octet-stream"); + res.end(); +}); +``` + +## Completion handler + +To find out how the transfer ended (to release the source of the data, or to log +a failure), set a handler that is called once after the body has been written: + +```cpp +res.set_chunked_completion_handler([](bool clean) { + if (!clean) + CROW_LOG_WARNING << "chunked transfer did not finish cleanly"; +}); +``` + +`clean` is `#!cpp true` when the provider finished normally +(`#!cpp crow::chunk_result::done`, or `#!cpp false` from the `bool` provider) +and every write succeeded; it is `#!cpp false` when the provider aborted or a +write error occurred. The handler runs on the connection's thread, after the +last write and before the response is finalized. + ## Notes !!! note diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index 76f42638eb..a86a4ab54f 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -365,20 +365,29 @@ namespace crow // closed by the deadline while chunks are still on their way. cancel_deadline_timer(); - // do_write_sync() clears the response on every write, so the provider has to be - // taken out of it before the loop starts. - auto provider = std::move(res.chunk_provider_); + // do_write_sync() clears the response on every write, so the provider and the + // completion handler have to be taken out of it before the loop starts. + auto provider = std::move(res.chunk_provider_ex_); + res.chunk_provider_ex_ = nullptr; + if (!provider && res.chunk_provider_) + { + provider = [bool_provider = std::move(res.chunk_provider_)](std::string& chunk) { + return bool_provider(chunk) ? response::chunk_result::more : response::chunk_result::done; + }; + } res.chunk_provider_ = nullptr; + auto completion_handler = std::move(res.chunk_complete_); + res.chunk_complete_ = nullptr; std::string chunk; std::string chunk_header; std::vector buffers{3}; - bool more = static_cast(provider); - while (more && !ec) + auto result = provider ? response::chunk_result::more : response::chunk_result::done; + while (result == response::chunk_result::more && !ec) { chunk.clear(); - more = provider(chunk); - if (chunk.empty()) + result = provider(chunk); + if (result == response::chunk_result::abort || chunk.empty()) { continue; } @@ -395,7 +404,9 @@ namespace crow } } - if (!ec) + // The terminating frame marks the body as complete, so it is only sent when the + // provider finished cleanly and every previous write succeeded. + if (result == response::chunk_result::done && !ec) { static const std::string last_chunk = "0\r\n\r\n"; std::vector tail{1}; @@ -407,7 +418,22 @@ namespace crow } } - if (close_connection_) + const bool aborted = (result == response::chunk_result::abort); + if (aborted) + { + // Close the connection forcefully, without the terminating frame, so that the + // client sees a truncated body instead of a seemingly complete one. + adaptor_.shutdown_readwrite(); + adaptor_.close(); + CROW_LOG_DEBUG << this << " from write (chunked, aborted)"; + } + + if (completion_handler) + { + completion_handler(result == response::chunk_result::done && !ec); + } + + if (close_connection_ && !aborted) { adaptor_.shutdown_readwrite(); adaptor_.close(); @@ -421,7 +447,7 @@ namespace crow // The deadline was cancelled for the duration of the transfer, so a kept-alive // connection has to be put back into reading state explicitly. - if (!close_connection_ && need_to_start_read_after_complete_) + if (!aborted && !close_connection_ && need_to_start_read_after_complete_) { need_to_start_read_after_complete_ = false; start_deadline(); diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 7977b27132..b8a7c84c1d 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -35,6 +35,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 { @@ -64,6 +72,24 @@ namespace crow /// string empty is allowed and sends no chunk. 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. Leaving the string empty is allowed + /// and sends no chunk. + using chunk_provider_ex_t = std::function; + + /// Handler called once after the chunked body has been written (or writing has stopped). + + /// + /// `clean` is `true` when the provider finished with `chunk_result::done` and every + /// write succeeded, `false` when the provider aborted or a write error occurred. + 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) { @@ -192,6 +218,8 @@ namespace crow completed_ = r.completed_; file_info = std::move(r.file_info); chunk_provider_ = std::move(r.chunk_provider_); + chunk_provider_ex_ = std::move(r.chunk_provider_ex_); + chunk_complete_ = std::move(r.chunk_complete_); return *this; } @@ -209,6 +237,8 @@ namespace crow completed_ = false; file_info = static_file_info{}; chunk_provider_ = nullptr; + chunk_provider_ex_ = nullptr; + chunk_complete_ = nullptr; } /// Return a "Temporary Redirect" response. @@ -265,6 +295,7 @@ namespace crow if (skip_body) { chunk_provider_ = nullptr; + chunk_provider_ex_ = nullptr; set_header("Content-Length", std::to_string(body.size())); body = ""; manual_length_header = true; @@ -300,7 +331,7 @@ namespace crow /// Check whether the response body is produced by a chunk provider. bool is_chunked_type() const { - return static_cast(chunk_provider_); + return static_cast(chunk_provider_) || static_cast(chunk_provider_ex_); } /// Send the response body in chunks produced on demand, without holding it in memory. @@ -311,7 +342,22 @@ namespace crow /// provider runs on the connection thread while the response is being written. void set_chunked_content_provider(chunk_provider_t provider, std::string content_type = "") { - chunk_provider_ = std::move(provider); + 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. + void set_chunked_content_provider(chunk_provider_ex_t provider, std::string content_type = "") + { + chunk_provider_ex_ = std::move(provider); manual_length_header = true; set_header("Transfer-Encoding", "chunked"); if (!content_type.empty()) @@ -320,6 +366,17 @@ namespace crow } } + /// Set a handler called once after the chunked body has been written (or writing has stopped). + + /// + /// The handler runs on the connection thread before the response is finalized. 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. + 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. /// @@ -495,5 +552,7 @@ namespace crow std::function is_alive_helper_; static_file_info file_info; chunk_provider_t chunk_provider_; + chunk_provider_ex_t chunk_provider_ex_; + chunk_complete_t chunk_complete_; }; } // namespace crow diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 6331b7f504..0abafdca1f 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -2202,6 +2203,116 @@ TEST_CASE("chunked_response_head_request") 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.0\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.0\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.0\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 + #ifdef CROW_ENABLE_COMPRESSION TEST_CASE("zlib_compression") { From 0981e289e2e1fd31167a14be7c99e42978795cda Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 17:05:27 +0200 Subject: [PATCH 06/16] Keep chunked headers intact for HEAD responses A HEAD response to a chunked route used to get Content-Length: 0 while Transfer-Encoding: chunked was still set, sending both headers at once (forbidden by RFC 7230) and misrepresenting what a GET would return. Now the provider is dropped, the body stays empty, Transfer-Encoding: chunked is kept and Content-Length is not set. --- include/crow/http_response.h | 23 ++++++++++++++++++----- tests/unittest.cpp | 9 +++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/include/crow/http_response.h b/include/crow/http_response.h index b8a7c84c1d..4e1cb04215 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -294,11 +294,24 @@ namespace crow completed_ = true; if (skip_body) { - chunk_provider_ = nullptr; - chunk_provider_ex_ = nullptr; - set_header("Content-Length", std::to_string(body.size())); - body = ""; - manual_length_header = true; + if (is_chunked_type()) + { + // A response to HEAD must carry the same header fields a GET would + // produce; with a chunk provider the body length is unknown, so + // "Transfer-Encoding: chunked" is kept and "Content-Length" is not + // set (RFC 7230 forbids sending both at once). The body itself is + // skipped, so the provider is dropped without being called. + chunk_provider_ = nullptr; + chunk_provider_ex_ = nullptr; + body = ""; + manual_length_header = true; + } + else + { + set_header("Content-Length", std::to_string(body.size())); + body = ""; + manual_length_header = true; + } } if (complete_request_handler_) { diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 0abafdca1f..493aeb28d0 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2198,6 +2198,15 @@ TEST_CASE("chunked_response_head_request") std::string response = HttpClient::request(LOCALHOST_ADDRESS, 45451, "HEAD /chunks HTTP/1.0\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); app.stop(); From ec62cee7d2a5bec9d002d1ca7fb48c0a0996376b Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 17:05:42 +0200 Subject: [PATCH 07/16] Remove a previously set Content-Length when enabling chunked transfer A handler that set Content-Length before calling set_chunked_content_provider() would send both Content-Length and Transfer-Encoding: chunked, which RFC 7230 forbids. The header is now erased when the provider is installed; the bool overload delegates to the chunk_result one, so both are covered. --- include/crow/http_response.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 4e1cb04215..8fcd9628a8 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -368,10 +368,13 @@ namespace crow /// 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. void set_chunked_content_provider(chunk_provider_ex_t provider, std::string content_type = "") { chunk_provider_ex_ = std::move(provider); manual_length_header = true; + headers.erase("Content-Length"); set_header("Transfer-Encoding", "chunked"); if (!content_type.empty()) { From f5ab73f3d9b4ae11c51989a4345544c3772fe6c6 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 17:05:55 +0200 Subject: [PATCH 08/16] Carry response flags over in the move assignment operator skip_body, manual_length_header and (under CROW_ENABLE_COMPRESSION) compressed were left at their defaults when a response was move-assigned. A moved chunked response would then have manual_length_header == false and write_header_into_buffer() would append Content-Length: 0 next to Transfer-Encoding: chunked. --- include/crow/http_response.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 8fcd9628a8..f4a1846f9a 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -217,6 +217,11 @@ namespace crow headers = std::move(r.headers); completed_ = r.completed_; file_info = std::move(r.file_info); +#ifdef CROW_ENABLE_COMPRESSION + compressed = r.compressed; +#endif + skip_body = r.skip_body; + manual_length_header = r.manual_length_header; chunk_provider_ = std::move(r.chunk_provider_); chunk_provider_ex_ = std::move(r.chunk_provider_ex_); chunk_complete_ = std::move(r.chunk_complete_); From aefa8f4f3f4c6c0e48830666a21a0d9bf628ddfb Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 17:06:25 +0200 Subject: [PATCH 09/16] Treat an exception thrown by the chunk provider as an abort The provider used to be called outside any try/catch, so an exception would propagate into the Asio write path. It is now caught in do_write_chunked(), logged, and handled exactly like chunk_result::abort: no terminating frame, forced close, completion handler called with clean == false, no return to keep-alive reading. --- include/crow/http_connection.h | 17 +++++++++- include/crow/http_response.h | 4 ++- tests/unittest.cpp | 57 ++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 2 deletions(-) diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index a86a4ab54f..81c65a4f74 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -386,7 +386,22 @@ namespace crow while (result == response::chunk_result::more && !ec) { chunk.clear(); - result = provider(chunk); + // An exception from the provider must not escape into the Asio stack; it is + // treated as an abort: no terminating frame, forced close, completion(false). + try + { + result = provider(chunk); + } + catch (const std::exception& e) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunk provider: " << e.what(); + result = response::chunk_result::abort; + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunk provider."; + result = response::chunk_result::abort; + } if (result == response::chunk_result::abort || chunk.empty()) { continue; diff --git a/include/crow/http_response.h b/include/crow/http_response.h index f4a1846f9a..21a5285699 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -357,7 +357,9 @@ namespace crow /// /// 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 thread while the response is being written. + /// provider runs on the connection thread while the response is being written. 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( diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 493aeb28d0..1bd74f9ff4 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2322,6 +2322,63 @@ TEST_CASE("chunked_response_completion_handler") app.stop(); } // chunked_response_completion_handler +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.0\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 + #ifdef CROW_ENABLE_COMPRESSION TEST_CASE("zlib_compression") { From 20260270d79b1bdb1977fa79ab0f4946f3a6eece Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 17:06:49 +0200 Subject: [PATCH 10/16] Document the contract for providers with no data at hand An empty chunk is allowed as an occasional occurrence; a provider that has no data yet should block until data is available or finish the transfer, since returning empty chunks in a tight loop spins the connection thread needlessly. Stated in the doxygen of both provider types and in the streaming guide. --- docs/guides/streaming.md | 6 ++++-- include/crow/http_response.h | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md index fe9487ba22..421f80e0b8 100644 --- a/docs/guides/streaming.md +++ b/docs/guides/streaming.md @@ -15,8 +15,10 @@ bool provider(std::string& chunk); Fill `chunk` with the next piece of the body and return `#!cpp true` while more data is coming, `#!cpp false` on the last invocation. Leaving `chunk` empty is -allowed and sends nothing, which is handy when the source of the data has -produced no bytes yet. +allowed as an occasional occurrence and sends nothing. A provider that has no +data yet should block until data is available (or finish the transfer): the +provider is called again immediately, so returning `#!cpp true` with an empty +chunk in a tight loop spins the connection thread needlessly. ### Example diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 21a5285699..f2abe2a851 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -69,7 +69,9 @@ namespace crow /// /// 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. Leaving the - /// string empty is allowed and sends no chunk. + /// string empty is allowed as an occasional occurrence and sends no chunk; a provider + /// that has no data yet should block until data is available (or finish), since + /// returning empty chunks in a tight loop spins the connection thread needlessly. using chunk_provider_t = std::function; /// Outcome of a single chunk provider invocation; see crow::chunk_result. @@ -80,7 +82,9 @@ namespace crow /// /// The provider fills the given string with the next chunk of the body and returns /// a chunk_result describing how to proceed. Leaving the string empty is allowed - /// and sends no chunk. + /// as an occasional occurrence and sends no chunk; a provider that has no data yet + /// should block until data is available (or return `done`/`abort`), since returning + /// `more` with empty chunks in a tight loop spins the connection thread needlessly. using chunk_provider_ex_t = std::function; /// Handler called once after the chunked body has been written (or writing has stopped). From 8bf91623b3c66f47ec41f49884b2f9b5c1c3fe64 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 17:07:23 +0200 Subject: [PATCH 11/16] Use HTTP/1.1 in the chunked response tests Chunked transfer encoding belongs to HTTP/1.1, so the tests now send HTTP/1.1 requests with a Host header, matching the other 1.1 tests. The basic test also sends a second request on the same connection to verify that a kept-alive connection goes back to reading state after the chunked transfer. --- tests/unittest.cpp | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 1bd74f9ff4..bb161f3d16 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2093,7 +2093,7 @@ TEST_CASE("chunked_response") app.wait_for_server_start(); HttpClient client(LOCALHOST_ADDRESS, 45451); - client.send("GET /chunks HTTP/1.0\r\n\r\n"); + 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) @@ -2106,6 +2106,16 @@ TEST_CASE("chunked_response") 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 @@ -2127,7 +2137,7 @@ TEST_CASE("chunked_response_no_data") app.wait_for_server_start(); HttpClient client(LOCALHOST_ADDRESS, 45451); - client.send("GET /empty HTTP/1.0\r\n\r\n"); + 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) @@ -2163,7 +2173,7 @@ TEST_CASE("chunked_response_large_body") app.wait_for_server_start(); HttpClient client(LOCALHOST_ADDRESS, 45451); - client.send("GET /large HTTP/1.0\r\n\r\n"); + 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) @@ -2196,7 +2206,7 @@ TEST_CASE("chunked_response_head_request") 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.0\r\n\r\n"); + 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. @@ -2236,7 +2246,7 @@ TEST_CASE("chunked_response_abort") app.wait_for_server_start(); HttpClient client(LOCALHOST_ADDRESS, 45451); - client.send("GET /abort HTTP/1.0\r\n\r\n"); + 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). @@ -2298,7 +2308,7 @@ TEST_CASE("chunked_response_completion_handler") { HttpClient client(LOCALHOST_ADDRESS, 45451); - client.send("GET /done HTTP/1.0\r\n\r\n"); + 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(); @@ -2307,7 +2317,7 @@ TEST_CASE("chunked_response_completion_handler") { HttpClient client(LOCALHOST_ADDRESS, 45451); - client.send("GET /abort HTTP/1.0\r\n\r\n"); + client.send("GET /abort HTTP/1.1\r\nHost: localhost\r\n\r\n"); try { while (true) @@ -2351,7 +2361,7 @@ TEST_CASE("chunked_response_throwing_provider") app.wait_for_server_start(); HttpClient client(LOCALHOST_ADDRESS, 45451); - client.send("GET /throw HTTP/1.0\r\n\r\n"); + 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. From 485e6091e01f84e0b63649604de68a461c725ba4 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 17:07:38 +0200 Subject: [PATCH 12/16] Strengthen the large body test with real chunked decoding Counting occurrences of the hex size line only proved a lower bound. The test now walks the chunked body frame by frame and checks the exact frame count and the exact decoded body length. --- tests/unittest.cpp | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/unittest.cpp b/tests/unittest.cpp index bb161f3d16..e73fd7a729 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2181,12 +2181,28 @@ TEST_CASE("chunked_response_large_body") CHECK(response.find("Transfer-Encoding: chunked") != std::string::npos); - // every chunk carries its size in hex, 1024 bytes being "400" + // 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; - for (std::string::size_type pos = response.find("400\r\n"); pos != std::string::npos; - pos = response.find("400\r\n", pos + 1)) + 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; - CHECK(seen >= chunk_count); + 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 From 44a443e99942cc25c8790e8defaf0ff0b1175ce8 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 19:47:35 +0200 Subject: [PATCH 13/16] Keep skip_body out of response move-assignment The router marks a HEAD request by setting skip_body on the connection's response before the handler runs. Copying the flag from the source response in operator= let a handler that assigns a freshly built response reset it, so HEAD responses to plain routes carried the GET body again (caught by the http_method test). --- include/crow/http_response.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/include/crow/http_response.h b/include/crow/http_response.h index f2abe2a851..694fd55c89 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -224,7 +224,9 @@ namespace crow #ifdef CROW_ENABLE_COMPRESSION compressed = r.compressed; #endif - skip_body = r.skip_body; + // 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; chunk_provider_ = std::move(r.chunk_provider_); chunk_provider_ex_ = std::move(r.chunk_provider_ex_); From 2fb5f6d8846b58e748fab1bbe29b4471b829dbc7 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 19:48:07 +0200 Subject: [PATCH 14/16] Treat mid-transfer write errors like abort and guard the completion handler A write failure after the headers or a chunk leaves the message framing incomplete: the terminating frame is never sent. Restarting keep-alive reads on such a connection desynchronizes it, so write errors now follow the same connection policy as an explicit abort: force the close and do not reuse the socket. The completion handler is also invoked under try/catch now, matching the provider: an exception escaping it skipped the connection cleanup and could propagate into the Asio callback stack. --- docs/guides/streaming.md | 6 ++++++ include/crow/http_connection.h | 27 +++++++++++++++++++----- tests/unittest.cpp | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md index 421f80e0b8..7a1a70c546 100644 --- a/docs/guides/streaming.md +++ b/docs/guides/streaming.md @@ -110,3 +110,9 @@ last write and before the response is finalized. A response to a `HEAD` request never calls the provider: the headers are sent and the body is skipped. + +!!! note + + A write error in the middle of the transfer is treated like `abort` as far + as the connection is concerned: the terminating frame is not sent and the + connection is closed instead of being reused for keep-alive. diff --git a/include/crow/http_connection.h b/include/crow/http_connection.h index 81c65a4f74..bf2e5b3d1d 100644 --- a/include/crow/http_connection.h +++ b/include/crow/http_connection.h @@ -433,22 +433,39 @@ namespace crow } } + // A write failure leaves the message framing just as incomplete as an explicit + // abort (the terminating frame never made it out), so the connection policy is + // the same for both: force the close and never reuse the socket for keep-alive. const bool aborted = (result == response::chunk_result::abort); - if (aborted) + const bool force_close = aborted || static_cast(ec); + if (force_close) { // Close the connection forcefully, without the terminating frame, so that the // client sees a truncated body instead of a seemingly complete one. adaptor_.shutdown_readwrite(); adaptor_.close(); - CROW_LOG_DEBUG << this << " from write (chunked, aborted)"; + CROW_LOG_DEBUG << this << " from write (chunked, " << (aborted ? "aborted" : "write error") << ")"; } if (completion_handler) { - completion_handler(result == response::chunk_result::done && !ec); + // An exception from the handler must not escape into the Asio stack or skip + // the cleanup below; it is logged and swallowed. + try + { + completion_handler(result == response::chunk_result::done && !ec); + } + 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_ && !aborted) + if (close_connection_ && !force_close) { adaptor_.shutdown_readwrite(); adaptor_.close(); @@ -462,7 +479,7 @@ namespace crow // The deadline was cancelled for the duration of the transfer, so a kept-alive // connection has to be put back into reading state explicitly. - if (!aborted && !close_connection_ && need_to_start_read_after_complete_) + if (!force_close && !close_connection_ && need_to_start_read_after_complete_) { need_to_start_read_after_complete_ = false; start_deadline(); diff --git a/tests/unittest.cpp b/tests/unittest.cpp index e73fd7a729..e4f99d0a4e 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2348,6 +2348,44 @@ TEST_CASE("chunked_response_completion_handler") 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_response_throwing_provider") { SimpleApp app; From 8c6cf80b7fd573b9a342b5685acfa0c4d11a2f75 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 19:48:48 +0200 Subject: [PATCH 15/16] Run the chunked completion handler for HEAD requests The docs present the completion handler as the place to release the source of the data, but a HEAD response dropped the provider and never invoked the handler: the write went through the general path and res.clear() discarded it silently. The handler now runs (with clean == true) at the point the providers are dropped, so it stays the single release point regardless of the request method. --- docs/guides/streaming.md | 9 +++++++-- include/crow/http_response.h | 24 ++++++++++++++++++++++-- tests/unittest.cpp | 13 ++++++++++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/docs/guides/streaming.md b/docs/guides/streaming.md index 7a1a70c546..9cba015702 100644 --- a/docs/guides/streaming.md +++ b/docs/guides/streaming.md @@ -90,7 +90,11 @@ res.set_chunked_completion_handler([](bool clean) { (`#!cpp crow::chunk_result::done`, or `#!cpp false` from the `bool` provider) and every write succeeded; it is `#!cpp false` when the provider aborted or a write error occurred. The handler runs on the connection's thread, after the -last write and before the response is finalized. +last write and before the response is finalized. For a `HEAD` request the +provider is never called, but the handler still runs (with `clean == true`) +when the response ends, so it is a reliable place to release the source of +the data. The handler should not throw: an exception that escapes it is +logged and swallowed. ## Notes @@ -109,7 +113,8 @@ last write and before the response is finalized. !!! note A response to a `HEAD` request never calls the provider: the headers are sent - and the body is skipped. + and the body is skipped. The completion handler still runs, with + `clean == true`. !!! note diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 694fd55c89..4aaa4cc15d 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -311,11 +311,27 @@ namespace crow // produce; with a chunk provider the body length is unknown, so // "Transfer-Encoding: chunked" is kept and "Content-Length" is not // set (RFC 7230 forbids sending both at once). The body itself is - // skipped, so the provider is dropped without being called. + // skipped, so the provider is dropped without being called. The + // completion handler is still invoked (with clean == true) so that + // it remains the single release point for the data source no matter + // which method the client used. chunk_provider_ = nullptr; chunk_provider_ex_ = nullptr; body = ""; manual_length_header = true; + if (chunk_complete_) + { + auto completion_handler = std::move(chunk_complete_); + chunk_complete_ = nullptr; + try + { + completion_handler(true); + } + catch (...) + { + CROW_LOG_ERROR << "An uncaught exception occurred in the chunked completion handler."; + } + } } else { @@ -400,7 +416,11 @@ namespace crow /// /// The handler runs on the connection thread before the response is finalized. 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. + /// or `false` from the `chunk_provider_t` overload) and every write succeeded. For a + /// HEAD request the body is skipped and the provider is never called, but the handler + /// still runs (with `clean == true`) when the response ends, so it remains a reliable + /// place to release the source of the data. 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); diff --git a/tests/unittest.cpp b/tests/unittest.cpp index e4f99d0a4e..7b63904631 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2211,11 +2211,16 @@ TEST_CASE("chunked_response_head_request") { SimpleApp app; - CROW_ROUTE(app, "/chunks").methods("GET"_method, "HEAD"_method)([](const crow::request&, crow::response& res) { + 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(); }); @@ -2235,6 +2240,12 @@ TEST_CASE("chunked_response_head_request") 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 From 408cadf10822740b78eb10937001462031e8bd60 Mon Sep 17 00:00:00 2001 From: Sergey Subbotin Date: Thu, 6 Aug 2026 19:49:12 +0200 Subject: [PATCH 16/16] Make the chunk provider and the static file mutually exclusive complete_request prefers the static path over the chunked one, so a response carrying both sent the raw file bytes while the headers still announced "Transfer-Encoding: chunked" next to the file's "Content-Length" - conflicting framing either way the calls were ordered. Each setter now discards the other body source together with its framing header: the source configured last wins. --- include/crow/http_response.h | 15 ++++++++++++++- tests/unittest.cpp | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/include/crow/http_response.h b/include/crow/http_response.h index 4aaa4cc15d..4c3a92f7c4 100644 --- a/include/crow/http_response.h +++ b/include/crow/http_response.h @@ -398,10 +398,14 @@ namespace crow /// `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. + /// 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 = "") { chunk_provider_ex_ = std::move(provider); + file_info = static_file_info{}; + body.clear(); manual_length_header = true; headers.erase("Content-Length"); set_header("Transfer-Encoding", "chunked"); @@ -448,6 +452,15 @@ 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. + chunk_provider_ = nullptr; + chunk_provider_ex_ = nullptr; + chunk_complete_ = nullptr; + 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 diff --git a/tests/unittest.cpp b/tests/unittest.cpp index 7b63904631..58dcf9a9f8 100644 --- a/tests/unittest.cpp +++ b/tests/unittest.cpp @@ -2397,6 +2397,37 @@ TEST_CASE("chunked_response_throwing_completion_handler") 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;