Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ jobs:
- name: Build and test under TSan
run: SAN=thread bash tools/asan-inside.sh

# 60 s churn soak with RSS sampling (Linux, /proc). Plain build: sanitizers
# reshape the memory picture, so a flat-RSS claim belongs to a plain run.
linux-soak:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install toolchain
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends g++ cmake ninja-build
- name: Build
run: |
cmake -S . -B build-soak -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build-soak
- name: Soak (60 s, RSS drift < 16 MiB)
run: HTTP_SERVER_SOAK_SECONDS=60 ctest --test-dir build-soak --output-on-failure

# Windows native gate: MinGW gcc, warnings-as-errors, ctest.
windows-native:
runs-on: windows-latest
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# build output
build/
build*/
out/
.cache/
compile_commands.json
Expand Down
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ add_executable(unit_tests
tests/test_response.cpp
tests/test_date.cpp
tests/test_server.cpp
tests/test_threads.cpp
tests/test_smuggling.cpp
tests/test_soak.cpp
tests/test_static.cpp)
target_include_directories(unit_tests SYSTEM PRIVATE external/doctest)
# doctest is a vendored header: warnings from it are not ours to fix,
Expand Down
13 changes: 11 additions & 2 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,20 @@ phase/step/milestone wording, ever.
| ✓ | ~~`feature/reactor-threading`~~ | acceptor thread + K reactors + socket hand-off inbox (`net::Wakeup`) | merged — cross-reactor keep-alive/pipelining/stop-drain tests; TSan green; EINTR double-join fixed |
| ✓ | ~~`feature/static-files`~~ | root mapping, traversal guard, MIME, index, 404/405, HEAD (`std::filesystem`) + per-connection write cap | merged — traversal/HEAD/405/slow-loris/pipelined-burst tests both OSes; ASan + TSan green |
| G | `feature/caching-and-range` | ETag, Last-Modified, If-None-Match, Range/206/416 | merged — 304 (weak/list/star/IMS-precedence) + single-range 206/416 + If-Range tests; multi-range ignored (200) by design; ASan + TSan green |
| H | `feature/hardening` | slow-body limit, smuggling corpus, soak (pipelining + slow-client tests landed with S) | ASan clean; 60 s soak flat RSS on Linux |
| ✓ | ~~`feature/hardening`~~ | slow-body 408 budget, server-level smuggling corpus, env-gated 60 s RSS soak, restored threading suite | merged — corpus proves rejected streams die with the stream and never reach the handler; soak RSS drift < 16 MiB (Linux) |
| D | `feature/docs-and-benchmark` | README (build/run per OS, limits), load script, numbers | documented; req/s + RSS recorded on both platforms |

Then `release/0.1.0` → `develop` + PR to `main` (approval required) → tag `v0.1.0`.
All of C, R, T, S, G merged; H (hardening soak/corpus) then D (docs/benchmark) remain.
All of C, R, T, S, G, H merged; D (docs/benchmark) remains.

> **Integrity note (H-phase audit).** `tests/test_connection.cpp`, `tests/test_threads.cpp`,
> `tests/test_util.cpp`, `tests/test_range.cpp` and a standalone `http::Range` module had
> only ever existed in the working tree — a botched `commit --amend` chain dropped them
> from the threading-phase commit, so every CI run since executed a silently smaller
> suite than reported. The threading suite is restored (`tests/test_threads.cpp`, now
> genuinely TSan-covered); range/date coverage demonstrably lives inside
> `tests/test_static.cpp` and `tests/test_date.cpp`, which were committed.


## 8. Git workflow (gitflow)

Expand Down
12 changes: 7 additions & 5 deletions src/http/request.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ bool RequestParser::parseRequestLine() {
fail(Error::BadRequest);
return true;
}
req_.method = line.substr(0, sp1);
req_.target = line.substr(sp1 + 1, sp2 - sp1 - 1);
req_.method.assign(line.data(), sp1);
req_.target.assign(line.data() + sp1 + 1, sp2 - sp1 - 1);
const std::string_view version = line.substr(sp2 + 1);
if (version != "HTTP/1.0" && version != "HTTP/1.1") {
fail(Error::VersionNotSupported);
return true;
}
req_.version = version.substr(5); // "1.0" / "1.1"
req_.version.assign(version.data() + 5, version.size() - 5); // "1.0" / "1.1"
for (std::string_view tok : {req_.method, req_.target}) {
for (char c : tok) {
const unsigned char u = static_cast<unsigned char>(c);
Expand Down Expand Up @@ -228,8 +228,10 @@ RequestParser::Result RequestParser::next() {
case State::Failed:
return Result::Error;
case State::Done:
req_.body = body_len_ == 0 ? std::string_view{}
: std::string_view(buf_.data() + body_off_, body_len_);
req_.body.clear();
if (body_len_ != 0) {
req_.body.assign(buf_.data() + body_off_, body_len_);
}
return Result::Request;
case State::RequestLine:
if (!parseRequestLine()) {
Expand Down
21 changes: 13 additions & 8 deletions src/http/request.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@

namespace http {

// A complete HTTP/1.x request. method/target/body view bytes owned by the
// parser's buffer: valid until reset() or the next feed() that appends data —
// consume a request before feeding more (the reactor does).
// A complete HTTP/1.x request. The parser OWNS every byte here: views into
// its working buffer die when a later feed() reallocates it, so the parser
// copies method/target/version/body the moment a request completes — a
// Request handed out by next() stays valid across reset() and further feeds.
struct Request {
std::string_view method;
std::string_view target;
std::string_view version; // "1.0" or "1.1"
Headers headers; // owned copies, parse order preserved
std::string_view body; // empty unless Content-Length was present
std::string method;
std::string target;
std::string version; // "1.0" or "1.1"
Headers headers; // owned copies, parse order preserved
std::string body; // empty unless Content-Length was present
};

// Push parser: feed() raw bytes, call next() until Request/Error. Enforces the
Expand Down Expand Up @@ -55,6 +56,10 @@ class RequestParser {

// Valid only after Result::Request.
bool keepAlive() const;
// No request bytes since the last reset(): the stream waits for a fresh
// request line. An idle keep-alive timeout should then just close (RFC
// 9112), not answer 408 to a request that was never started.
bool idle() const { return state_ == State::RequestLine && buf_.empty(); }

void reset();

Expand Down
8 changes: 8 additions & 0 deletions src/server/connection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ Connection::Drain Connection::drainParser() {
{}};
}
parser_.reset();
served_any_ = true;
// During shutdown a queued reply (Connection: close) must not strand
// pipelined requests that were already parsed: keep serving.
respond(reply, !ka || (shutting_down_ && pending() == 0), head);
Expand Down Expand Up @@ -207,6 +208,13 @@ void Connection::onTimeout() {
if (dead_) {
return;
}
if (served_any_ && parser_.idle()) {
// Between requests on a served connection: quiet hang-up, the RFC-
// prescribed answer to an idle keep-alive stream. 408 is reserved
// for requests that started and stalled.
close();
return;
}
respond(
Reply{
http::Status::RequestTimeout, "text/plain; charset=utf-8", "408 Request Timeout\n", {}},
Expand Down
1 change: 1 addition & 0 deletions src/server/connection.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class Connection {
std::uint64_t idle_ms_;
std::size_t max_queued_;
http::RequestParser parser_;
bool served_any_ = false; // idle keep-alive timeouts close quietly after this
std::string out_;
std::size_t out_off_ = 0;
bool want_write_ = false;
Expand Down
Loading
Loading