Add a request body sink, with FileBodySink as a file-backed wrapper - #1235
Add a request body sink, with FileBodySink as a file-backed wrapper#1235anton-n-petrov wants to merge 15 commits into
Conversation
Content-Length is checked at headers-complete, before 100 Continue. Chunked bodies are counted in on_body. Either way the client gets 413 and the connection closes; the handler does not run and leftover bytes are not parsed as the next request. The cap applies to 404, 405, and slash-redirects as well as matched routes. UINT64_MAX remains unlimited. Addresses the unbounded-body half of CrowCpp#1064.
.body_sink() installs a per-request destination at headers-complete; the parser writes each on_body span into it. .body_file() is that sink with an exclusive fd kept open until finish(), 0600 on POSIX, and unlink unless take_body_file() transfers ownership. The file is created only when a body is coming. Write/close failures are 500 with Connection: close. Size limits come from max_body_size (the previous commit), not a file-only knob. Answers gittiver on CrowCpp#1233: the primitive is a stream/sink so targets without a filesystem can still accept large bodies.
Reserve req.body only when a finite max_body_size is in effect and the advertised length is within that cap. The unlimited default grows the buffer as bytes arrive, so a huge Content-Length cannot OOM a worker. Complete 413 through after-handlers only, skipping before-handlers, to match unmatched-route and auto-OPTIONS early rejects.
Keep over-cap 413 before 100-continue, open a per-request sink at headers-complete, and reserve req.body only for a finite cap on non-sink routes so an advertised Content-Length cannot allocate.
Setting both on one route keeps only the last of .body_file() and .body_sink(). Catch2 drives a real Crow app over TCP for no-reserve on sink/file routes, write/finish/open 500 with Connection: close, over-limit 413 leftover cleanup, concurrent unique files, and GET/Content-Length: 0 creating no file.
|
Reviewed at The branch is healthy: the full suite passes, 1104 assertions in 143 test cases, and the tests now use ephemeral ports. The file sink fixes what #1233 was sent back for: the descriptor is created with My overall read is rework, keeping the seam. The primitive is the right shape and it is what @gittiver asked for; the contract around it has holes that surfaced as soon as I put a misbehaving sink behind it, and the packaging pulls the file code into the core in a way that undoes the embedded argument. The sink contractA 1. Exceptions from
|
…eader Addresses ssubbotin's review on PR CrowCpp#1235 (rework, keeping the seam): - request::body_sink is now a public shared_ptr<BodySink> instead of a parser-owned unique_ptr plus body_file_path/take_body_file(); every copy of the request shares the sink, so a handler can hand it off (e.g. to a deferred response) without the file being deleted early, and calling "keep" on a copy now actually reaches the underlying sink. - FileBodySink moves to a new opt-in header, crow/file_body_sink.h, never included by crow.h or crow/parser.h, so the core no longer needs RTTI (verified building a core-only program with -fno-rtti -DASIO_NO_TYPEID). .body_file()/app.body_file_directory() are gone; the file sink is now .body_sink(crow::FileBodySink::factory(dir)), with FileBodySink::from(), ->path() and ->keep() replacing has_body_file()/take_body_file(). - write()/finish() are now called inside catch(...): an uncaught exception used to unwind into the worker loop (or end a non-std::exception thread silently) instead of producing a 500. - A nullptr from a user factory now means "keep the body in req.body" instead of 500; FileBodySink's own open failure throws instead, so it still 500s. - remote_ip_address is set before the sink factory runs instead of after, so a factory can apply a per-peer policy. - The file is no longer unlinked after just the first chunk of a streamed response: do_write_sync() only clears the parser (and drops the connection's body_sink reference) on the last write of a response, not every chunk. - FileBodySink::factory() resolves its directory to an absolute path once and requires it to already exist (throws otherwise), instead of a silently-discarded create_directories() at builder time against whatever the cwd happens to be later; the resolved path is cached instead of calling temp_directory_path() per request. Uses mkostemp on POSIX and CreateFileW (path.wstring()) on Windows. - Deduplicated the three near-identical rule-lookup blocks in routing.h behind a single matched_rule() helper. - Generalized the "lingering close" fix from CrowCpp#1234 (drain instead of hard-closing on a rejection, to avoid RSTing a client still mid-upload) from the 413 path to the new sink-failure 500 path; confirmed the regression test hangs without it. Rewrote tests/body_file_tests.cpp for the new API and added the coverage ssubbotin flagged as missing: throwing write()/finish() (including a non-std::exception throw), the copy-trap regression, the early-cleanup timing regression, a chunked over-limit request on a sink route, the nullptr-factory fallback, and a per-request (post-setup) open failure. Updated docs/guides/body-file.md, routes.md, and the example to match. Full suite passes: 1133 assertions, 150 test cases.
Replace mkostemp (glibc-only) with a portable open(O_CREAT|O_EXCL) loop using the same random-name scheme as the Windows branch, so the opt-in file_body_sink.h header builds on macOS/BSD. Fix app.md's stale claim that an over-limit body "is not read or drained" — it is, via linger_close()/do_linger_read(). Add explanatory comments on the linger-read buffer reuse and the uploads/ directory requirement in the example.
|
Pushed The sink contract
Your sketch under "What I would merge instead" is close to what landed: Other defects
Tests: added for a throwing Separately from this review, a later pass caught two more issues also fixed in Full suite passes: 1132 assertions, 150 test cases. |
| // when unlimited, and never when the body is going to a sink | ||
| // (req.body stays empty, whether the route always uses one or the | ||
| // factory declined this particular request). | ||
| req_.body.reserve(static_cast<size_t>(parser_.content_length)); |
There was a problem hiding this comment.
This reintroduces the allocation from an attacker-controlled Content-Length that the current #1234 head removed. With a large finite max_body_size, a client can send headers only and make every connection reserve the full advertised size before any body byte arrives, so concurrent requests can still exhaust memory. Please update this branch onto the current #1234 head and keep the unconditional removal of this reserve() path, together with req_.body.clear() on rejection.
| @@ -0,0 +1,219 @@ | |||
| #pragma once | |||
|
|
|||
| // Opt-in: not included by "crow.h" or "crow/parser.h". A build that has no | |||
There was a problem hiding this comment.
The amalgamated build violates this opt-in guarantee. scripts/merge_all.py globs every include/crow/*.h*, so file_body_sink.h is copied into crow_all.h; compiling that generated header with -fno-rtti -DASIO_NO_TYPEID then fails at the dynamic_cast in FileBodySink::from(). Please keep this header out of the amalgamated output, or remove its RTTI dependency, and add a generated-header compilation test for this configuration.
|
Please refresh the PR title and description after the API rewrite. They still advertise |
…reject The merge of feature/max-body-size into this branch (71c9f86) reintroduced a finite-cap-conditioned req_.body.reserve(content_length) that CrowCpp#1234 had already removed unconditionally on its own head (d24c987). A client can still send headers only, advertising a large Content-Length within a route's configured max_body_size, and make every such connection reserve that much memory before a single body byte arrives - concurrent connections can still exhaust memory this way, per ssubbotin's still-open review comment on include/crow/http_connection.h:163. Also port over the req_.body.clear() on the body_error_status_ reject path from the same upstream fix, so a chunked reject that buffered an accepted prefix and a Content-Length reject (always empty) leave after-handlers a consistent, empty request body.
scripts/merge_all.py globbed every include/crow/*.h* into the amalgamated header, so file_body_sink.h was pulled into crow_all.h even though it is documented as opt-in. Compiling that generated header with -fno-rtti -DASIO_NO_TYPEID (asio's supported RTTI-free configuration, per ssubbotin's review) then failed at the dynamic_cast in FileBodySink::from(). Exclude file_body_sink.h from the crow/*.h* glob via an opt_in_headers set, mirroring how middlewares are already assembled separately rather than glob-included unconditionally. Verified crow_all.h no longer references FileBodySink/dynamic_cast and compiles clean under -fno-rtti -DASIO_NO_TYPEID.
The guide said Crow generates unique file names via POSIX mkostemp, per ssubbotin's review comment. The implementation was changed to a retried open(O_CREAT | O_EXCL) loop for portability (mkostemp is glibc-only, unavailable on macOS/BSD and older musl) but the doc wasn't updated to match.
…terface handle_header() now returns int (0/1) instead of void, and the parser calls handler_->reject_body(status) and handler_->parser_should_abort() on the rejection/message-complete paths (the general form this branch's body-sink-failure handling generalized CrowCpp#1234's reject_payload_too_large() into). tests/fuzz/http_fuzzer.cpp's DummyHandler stand-in wasn't updated when the interface changed, so http_fuzzer.cpp failed to compile.
…endent - docs/guides/app.md: scope the "drained" wording to requests rejected while their body is still streaming in (chunked 413, sink 500). A request rejected on Content-Length alone has no body yet to drain; the connection is simply closed after the 413 write. - tests/unittest.cpp: the send_file test stat()'d and served "tests/img/..." relative to the process cwd, failing when unittest is run from anywhere but the build-dir root. CROW_STATIC_FILE's sanitizer rejects absolute Unix paths outright, so instead chdir into the CMake-provided repo root for the scope of the test (restored via RAII), keeping the paths relative as the API requires.
…-size This branch forked from feature/max-body-size right after 225034f, before d24c987 ("Address ssubbotin's PR review: linger on 413, drop reserve(), and cleanup") landed there, so it never picked up that commit's skip_body handling. Port the equivalent fix here: - handle(): explicitly set res.skip_body on the body_error_status_ branch (413 from an over-limit body, or 500 from a body_sink failure) instead of relying on response::operator=(&&) happening to omit the member. - write_header_into_buffer: skip the automatic status-text body when skip_body is set. Without this, setting skip_body explicitly surfaces a real bug: end() already writes "Content-Length: 0" while skip_body is true, but the default status text was still being force-appended to body afterward, so a HEAD 413/500 shipped a nonzero-length body under a Content-Length: 0 header. - Add a regression test covering a matched-route HEAD/OPTIONS request with an over-limit body (413, Connection: close, and for HEAD an empty body with Content-Length: 0), mirroring the one already added on feature/max-body-size.
docs/guides/app.md claimed the connection is "simply closed" with nothing to drain when a request is rejected on the advertised Content-Length alone. That's inaccurate: http_connection.h's do_read error path calls linger_close() for every body_error_status_ case, so the server always drains and discards whatever the client still sends, regardless of which check triggered the 413. Reworded to match the verified behavior. Also default-initialize routing_handle_result::rule_index so a default-constructed instance never carries an indeterminate value.
- do_write_static()/do_write_general() no longer shut the socket down themselves for a body-error (413/500) response; that's left to do_read()'s linger_close(), so a small stream_threshold() no longer races the drain and RSTs a peer still mid-upload. Default-threshold responses were never affected, which is why this was latent. - Add regression tests: the above with a 1-byte stream_threshold, and that a body_sink finish() failure sends exactly one response (traced through the parser, on_message_complete()'s existing early return already prevents the double-handle() the review described - this pins the current, correct behavior down). - Extract response_complete/status_of/http_body/TestClient, duplicated verbatim across max_body_size_tests.cpp and body_file_tests.cpp, into tests/http_test_utils.h. - Content-Length parsing in the test helpers now uses stoull instead of stoul. - Note the 32-bit/64-bit MAXDWORD cast is exact on both in FileBodySink's Windows WriteFile chunking loop.
| this->continue_requested = false; | ||
| } | ||
| else | ||
| else if (clear_parser) |
There was a problem hiding this comment.
[P2] This branch still clears the parser while a body-error callback is on the http_parser_execute stack. The small-response 413/500 path calls do_write_sync() with clear_parser == true from on_body or on_message_complete, and this branch lacks the guard added to #1234 in 17f1a187a. Please preserve that fix when rebasing: skip parser_.clear() while body_error_status_ is set. The current #1234 and #1235 heads also have five merge conflicts, so this needs explicit reconciliation.
|
|
||
| /// Destination for request body bytes as they arrive from the parser. | ||
| /// | ||
| /// `write()`/`finish()` run on the connection's io thread, at headers-complete, |
There was a problem hiding this comment.
[P2] The timing contract is incorrect. Only the factory runs at headers-complete before 100 Continue; write() runs after the body starts arriving, so an Expect: 100-continue request receives the interim 100 before a later write() failure can produce 500, and finish() runs at message-complete. Please distinguish factory timing from write()/finish() here and in docs/guides/body-file.md.
Addresses #1064, together with #1234.
PR 2 of 2
This is the other half of the split of #1233, following @ssubbotin's review ("Step 2: the spool, on an internal sink seam") and answering @gittiver's comment that a file is not always available (embedded/limited targets). #1233 is closed in favor of #1234 and this PR.
Branched from
feature/max-body-size(#1234) at225034f8e, since size limits here come frommax_body_size, not a file-only knob — this PR's diff carries that PR's first two commits (fa21b7fa1,225034f8e); the sink-only changes are225034f8e..HEAD. #1234 has since gained its own review fixes (lingering close on rejection, droppingreq.body'sreserve(), explicitskip_bodyon error responses); this PR reimplements each independently rather than re-merging, so the size-limit mechanics are reviewed once in #1234 and this diff stays sink-focused.What changed vs. #1233
#1233wrote every body straight to a file, reopened it by path, and drained an over-limit body instead of closing. This PR replaces that with:.body_sink(factory): the primitive — a per-requestBodySink(write()/finish()) that the parser feeds fromon_body, before theHostheader check, before middleware, and before100 Continue. No filesystem required, per @gittiver. A sink that throws, or returnsfalse, ends the request as a 500 with the connection closed; the route handler never runs.crow::FileBodySink: the built-in file-backed sink, handed to.body_sink()viaFileBodySink::factory(directory = {})(empty: system temp directory). Descriptor is createdO_CREAT|O_EXCL|O_WRONLY(Windows:CREATE_NEW), mode0600— nomkostemp, so no glibc dependency — and kept open (not reopened by path) untilfinish(). The file is unlinked when the sink is destroyed unlessFileBodySink::from(req)->keep()was called.crow::FileBodySinklives in its own opt-in header (crow/file_body_sink.h), excluded from the amalgamatedcrow_all.h; the tinyBodySink/BodySinkFactoryinterface (crow/body_sink.h) stays in the amalgam..body_sink()again on the same route is last-call-wins — it just replaces the stored factory.has_incoming_body()), not for GET/Content-Length: 0.req.bodyis never pre-reserve()d on a sink route — the size-limit reservation in Add a request body size limit that closes over-limit connections #1234 is conditional on!uses_body_sink(), so a route using the sink doesn't pay the full upload size in RAM before the sink even opens (which was the whole point of moving offreq.bodyin the first place).API
A custom sink just implements
crow::BodySink(write()/finish()) directly — no filesystem needed. Seedocs/guides/body-file.mdfor the full guide, including the empty-body edge case,keep()lifecycle, and the RTTI/opt-in rationale.multipart/form-datais unaffected — it's still parsed fromreq.body; saving individual multipart parts to disk is a separate feature.Testing
tests/body_file_tests.cppcovers in-memory vs. file vs. custom-sink routes,keep()surviving a copied request, the file staying open until the full response is sent, size-limit interaction (including chunked, over limit on a sink route), disconnect cleanup, concurrent uploads getting distinct paths,.body_sink()last-call-wins, a factory returningnullptr(keeps the body inreq.body), and the sink failure matrix (write()/finish()returningfalse, throwingstd::exception, throwing a non-std::exception, a throwing factory, per-request open failure, and a large in-flight upload finishing before the 500 is read). Full suite (ctest/unittest) passes: 1150 assertions, 153 test cases.