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
6 changes: 4 additions & 2 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ target_compile_options(net PRIVATE ${HTTP_WARNINGS})
# server: reactor + connection, platform-neutral; signals implementation is per OS.
add_library(server STATIC
src/server/server.cpp
src/server/connection.cpp)
src/server/connection.cpp
src/server/static_file.cpp)
target_link_libraries(server PUBLIC net http)
target_compile_options(server PRIVATE ${HTTP_WARNINGS})

Expand All @@ -92,7 +93,8 @@ add_executable(unit_tests
tests/test_socket.cpp
tests/test_request.cpp
tests/test_response.cpp
tests/test_server.cpp)
tests/test_server.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,
# hence SYSTEM above; our own test code still builds warning-clean.
Expand Down
6 changes: 3 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,13 @@ phase/step/milestone wording, ever.
| C | `feature/ci-matrix` | Actions matrix: Linux job = sanitizer container (`tools/asan-inside.sh`), Windows = native MinGW + ctest | merged — PR #5 gated itself green on first run |
| R | `feature/reactor-and-connection` | single-threaded poll reactor: connection state machine, timeouts (408), POLLOUT backpressure, signals (self-pipe / console ctrl), graceful drain | merged — 200/keep-alive/408/400/stop integration tests; ASan + TSan green; SIGINT-exit-0 smoke in the Linux gate |
| T | `feature/reactor-threading` | acceptor thread + K reactors + hand-off inbox; `Connection` stays owned by one reactor | TSan green on concurrent tests; RSS stays flat |
| S | `feature/static-files` | root mapping, traversal guard, MIME, index, 404/405, error pages (`std::filesystem`) | status-by-status integration tests; traversal rejected on both OSes |
| ✓ | ~~`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 | 304 + 206 tests incl. multi-range rejection |
| H | `feature/hardening` | pipelining, slow-body limit, max-body/max-heads, request-smuggling corpus, soak | pipelined + slow-client tests; ASan clean; 60 s soak flat RSS on Linux |
| 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 |
| 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`.
R merged single-threaded; S (static files) stacks on it — T (threading) can land after S.
R merged single-threaded; S (static files) merged on top — T (threading) can land next.

## 8. Git workflow (gitflow)

Expand Down
17 changes: 13 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,20 @@ until then.
- `http/` — status, headers, request parser, response serialization landed. Parser is
push/incremental, enforces head/body/count limits, and rejects bare-LF endings,
obsolete folding, duplicate `Content-Length`, `Content-Length`+`Transfer-Encoding`,
non-token names/methods, unknown versions. Pending: URL decode/normalise, MIME.
non-token names/methods, unknown versions. URL decode/normalise and MIME live
with the static handler (they are a serving concern, not a parsing one).
- `server/` — single-threaded poll reactor (`Server::run`): accept batch, per-connection
deadlines (408), backpressure via POLLOUT, graceful stop (drain queued responses,
grace cap, then drop). `Connection` = socket + `RequestParser` + output buffer;
protocol errors are answered once (best effort) then the connection drops —
deadlines (408), graceful stop (drain queued responses, grace cap, then drop).
`Connection` = socket + `RequestParser` + capped output buffer (`max_queued_bytes`):
a slow reader throttles the drain — queued responses stop growing, remaining
pipelined requests wait in the parser, POLLOUT or the next read resumes it.
Handlers return `Reply{status, content_type, body}`; HEAD keeps an honest
`Content-Length` and no body; handler exceptions become 500 at the reactor seam.
`StaticFiles` serves a canonical root: strict percent-decode before resolution,
traversal/encoded/backslash/absolute targets refused as 404 before touching disk
(symlink prefixes resolved via `weakly_canonical`, case-folded containment on
Windows), index.html for directories, 405 elsewhere, whole-file reads.
Protocol errors are answered once (best effort) then the connection drops —
smuggling candidates never share a stream. `ShutdownSignal` is the pluggable stop
trigger; tests drive it directly.
- `app/` — shutdown signals, one paragraph precisely because behaviour differs:
Expand Down
4 changes: 2 additions & 2 deletions src/app/signals.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace app {
// Windows: the handler sets a flag and blocks (a returning handler means "handled,
// exit now" would kill the graceful drain); the reactor notices within pollCap().
class ShutdownSignals final : public server::ShutdownSignal {
public:
public:
struct Impl; // OS state, defined in the per-OS .cpp

ShutdownSignals();
Expand All @@ -25,7 +25,7 @@ class ShutdownSignals final : public server::ShutdownSignal {
std::chrono::milliseconds pollCap() const override;
void notifyStopped() override;

private:
private:
std::unique_ptr<Impl> impl_;
};

Expand Down
6 changes: 4 additions & 2 deletions src/app/signals_posix.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ ShutdownSignals::ShutdownSignals() : impl_(std::make_unique<Impl>()) {
return; // degraded: reactor still re-checks after every poll
}
g_impl = impl_.get();
struct sigaction sa {};
struct sigaction sa{};
sa.sa_handler = onSignal;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // send/poll EINTR handling exists, but avoid churn
Expand All @@ -53,7 +53,9 @@ ShutdownSignals::~ShutdownSignals() {
}
}

bool ShutdownSignals::stopRequested() const { return g_stop.load(); }
bool ShutdownSignals::stopRequested() const {
return g_stop.load();
}

void ShutdownSignals::preparePoll(net::PollSet& set) {
if (impl_->pipe[0] >= 0) {
Expand Down
12 changes: 9 additions & 3 deletions src/app/signals_win.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,16 @@ ShutdownSignals::~ShutdownSignals() {
}
}

bool ShutdownSignals::stopRequested() const { return g_stop.load(); }
bool ShutdownSignals::stopRequested() const {
return g_stop.load();
}

void ShutdownSignals::preparePoll(net::PollSet& set) { (void)set; }
void ShutdownSignals::afterPoll(const net::PollSet& set) { (void)set; }
void ShutdownSignals::preparePoll(net::PollSet& set) {
(void)set;
}
void ShutdownSignals::afterPoll(const net::PollSet& set) {
(void)set;
}

std::chrono::milliseconds ShutdownSignals::pollCap() const {
return std::chrono::milliseconds(100); // no waitable socket+event union on WSAPoll
Expand Down
3 changes: 1 addition & 2 deletions src/http/headers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ bool isTokenChar(char c) {
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" /
// "_" / "`" / "|" / "~" / DIGIT / ALPHA
static const char* extras = "!#$%&'*+-.^_`|~";
return std::isalnum(static_cast<unsigned char>(c)) != 0 ||
std::strchr(extras, c) != nullptr;
return std::isalnum(static_cast<unsigned char>(c)) != 0 || std::strchr(extras, c) != nullptr;
}

std::string trimField(std::string value) {
Expand Down
4 changes: 2 additions & 2 deletions src/http/headers.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ bool validFieldValue(std::string_view value);
// Ordered header list with case-insensitive lookup. Values are trimmed of
// leading/trailing SP and HTAB on insertion (RFC 7230 obs-fold removal semantics).
class Headers {
public:
public:
struct Entry {
std::string name;
std::string value;
Expand All @@ -26,7 +26,7 @@ class Headers {
bool contains(std::string_view name) const { return get(name) != nullptr; }
const std::vector<Entry>& entries() const { return entries_; }

private:
private:
std::size_t find(std::string_view name) const;

std::vector<Entry> entries_;
Expand Down
8 changes: 6 additions & 2 deletions src/http/request.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
namespace http {
namespace {

bool isOWS(char c) { return c == ' ' || c == '\t'; }
bool isOWS(char c) {
return c == ' ' || c == '\t';
}

// Value bytes: printable, tab, or extended (obs); never CR/LF/NUL/other CTL.
bool isValueChar(char c) {
Expand All @@ -29,7 +31,9 @@ bool equalIgnoreCase(std::string_view a, std::string_view b) {

} // namespace

void RequestParser::feed(std::string_view data) { buf_.append(data); }
void RequestParser::feed(std::string_view data) {
buf_.append(data);
}

void RequestParser::fail(Error e) {
error_ = e;
Expand Down
8 changes: 4 additions & 4 deletions src/http/request.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct Request {
// obsolete header folding, duplicate Content-Length, Content-Length together
// with Transfer-Encoding, non-token method/name characters, unknown versions.
class RequestParser {
public:
public:
struct Limits {
std::size_t maxHeadBytes = 64 * 1024;
std::size_t maxBodyBytes = 8 * 1024 * 1024;
Expand Down Expand Up @@ -60,7 +60,7 @@ class RequestParser {

std::size_t bufferedBytes() const { return buf_.size() - pos_; }

private:
private:
enum class State { RequestLine, Headers, Body, Done, Failed };

bool parseRequestLine();
Expand All @@ -70,8 +70,8 @@ class RequestParser {

Limits limits_;
std::string buf_;
std::size_t pos_ = 0; // first unparsed byte (request line / header scan cursor)
std::size_t scan_ = 0; // offset already searched for the header terminator
std::size_t pos_ = 0; // first unparsed byte (request line / header scan cursor)
std::size_t scan_ = 0; // offset already searched for the header terminator
std::size_t head_start_ = 0; // first byte of the current request line
State state_ = State::RequestLine;
Error error_ = Error::None;
Expand Down
4 changes: 2 additions & 2 deletions src/http/response.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ bool Response::serializeHead(std::string& out) const {
return false; // RFC 7230: 204/304/1xx must not carry a body
}

std::string head = "HTTP/1.1 " + std::to_string(code) + " " +
std::string(reasonPhrase(status_)) + "\r\n";
std::string head =
"HTTP/1.1 " + std::to_string(code) + " " + std::string(reasonPhrase(status_)) + "\r\n";

bool haveLength = headers_.get("Content-Length") != nullptr;
for (const auto& entry : headers_.entries()) {
Expand Down
8 changes: 5 additions & 3 deletions src/http/response.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace http {
// Unless Transfer-Encoding declares chunked, a Content-Length is implied by the
// body and written automatically when not set explicitly.
class Response {
public:
public:
explicit Response(Status status = Status::OK) : status_(status) {}

Status status() const { return status_; }
Expand All @@ -36,7 +36,7 @@ class Response {
// Head + body. Empty string if head serialization fails.
std::string serialize() const;

private:
private:
bool chunked() const;

Status status_;
Expand All @@ -47,6 +47,8 @@ class Response {
// chunk-size (lowercase hex) CRLF data CRLF
std::string encodeChunk(std::string_view data);
// The zero-size final chunk.
inline std::string encodeLastChunk() { return "0\r\n\r\n"; }
inline std::string encodeLastChunk() {
return "0\r\n\r\n";
}

} // namespace http
4 changes: 3 additions & 1 deletion src/http/status.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ std::string_view reasonPhrase(Status status) {
return "Unknown";
}

bool statusOmitsBody(int code) { return code < 200 || code == 204 || code == 304; }
bool statusOmitsBody(int code) {
return code < 200 || code == 204 || code == 304;
}

} // namespace http
14 changes: 7 additions & 7 deletions src/main.cpp
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#include "app/signals.h"
#include "http/request.h"
#include "net/context.h"
#include "server/server.h"
#include "server/static_file.h"

#include <cstdio>
#include <cstdlib>
Expand All @@ -13,18 +13,18 @@ int main(int argc, char** argv) {
port = std::atoi(argv[1]);
}
if (port < 1 || port > 65535) {
std::fprintf(stderr, "usage: http_server [port]\n");
std::fprintf(stderr, "usage: http_server [port] [root]\n");
return 2;
}

try {
net::Context ctx;
app::ShutdownSignals signals;
server::Server server(static_cast<std::uint16_t>(port),
[](const http::Request&) {
return std::string("Hello from http-server-cpp\n");
});
std::printf("listening on http://127.0.0.1:%d/ (stop with Ctrl-C)\n", port);
const std::string root = argc > 2 ? argv[2] : ".";
server::StaticFiles files(root);
server::Server server(static_cast<std::uint16_t>(port), files);
std::printf("serving %s on http://127.0.0.1:%d/ (stop with Ctrl-C)\n",
files.root().string().c_str(), port);
std::fflush(stdout);
server.run(signals);
std::puts("stopped");
Expand Down
4 changes: 2 additions & 2 deletions src/net/context.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace net {
// Process-wide socket subsystem session. One instance, created before any Socket,
// destroyed after the last Socket dies. No-op on POSIX.
class Context {
public:
public:
Context();
~Context();
Context(const Context&) = delete;
Expand All @@ -16,7 +16,7 @@ class Context {
bool ok() const { return ok_; }
int version() const { return version_; } // Winsock level on Windows, 0 on POSIX

private:
private:
#ifdef _WIN32
WSADATA wsaData_;
#endif
Expand Down
6 changes: 3 additions & 3 deletions src/net/detail_raw.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ namespace detail {
// OS primitives. Error detail stays in the OS error slot (WSAGetLastError/errno);
// read it via net::lastError() immediately after a failed call.

handle_t rawSocket(); // stream socket, IPv4 (CloExec where supported)
int rawClose(handle_t); // 0 on success
handle_t rawSocket(); // stream socket, IPv4 (CloExec where supported)
int rawClose(handle_t); // 0 on success
bool rawSetNonBlocking(handle_t, bool);
handle_t rawAccept(handle_t listener); // raw; then call rawAfterAccept
handle_t rawAccept(handle_t listener); // raw; then call rawAfterAccept
// Make the accepted socket's blocking mode match the listener's, so the portable
// contract holds on both OSes: Windows inherits automatically, Linux does not.
void rawAfterAccept(handle_t listener, handle_t accepted);
Expand Down
8 changes: 3 additions & 5 deletions src/net/poll.h
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,15 @@ inline constexpr short PollNval = POLLNVAL;
// Thin poll()/WSAPoll fd set. Only the first pollSockets() return value of entries
// carry meaningful revents (Windows leaves the rest untouched).
class PollSet {
public:
void add(detail::handle_t socket, short events) {
fds_.push_back({socket, events, 0});
}
public:
void add(detail::handle_t socket, short events) { fds_.push_back({socket, events, 0}); }
void clear() { fds_.clear(); }
std::size_t size() const { return fds_.size(); }
detail::handle_t socket(std::size_t index) const { return fds_[index].fd; }
short revents(std::size_t index) const { return fds_[index].revents; }
detail::pollfd_t* data() { return fds_.data(); }

private:
private:
std::vector<detail::pollfd_t> fds_;
};

Expand Down
8 changes: 6 additions & 2 deletions src/net/raw_posix.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,13 @@
namespace net {
namespace detail {

handle_t rawSocket() { return ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); }
handle_t rawSocket() {
return ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
}

int rawClose(handle_t handle) { return ::close(handle); }
int rawClose(handle_t handle) {
return ::close(handle);
}

bool rawSetNonBlocking(handle_t handle, bool enabled) {
const int flags = ::fcntl(handle, F_GETFL, 0);
Expand Down
12 changes: 9 additions & 3 deletions src/net/raw_win.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@
namespace net {
namespace detail {

handle_t rawSocket() { return ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); }
handle_t rawSocket() {
return ::socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
}

int rawClose(handle_t handle) { return ::closesocket(handle); }
int rawClose(handle_t handle) {
return ::closesocket(handle);
}

bool rawSetNonBlocking(handle_t handle, bool enabled) {
u_long mode = enabled ? 1UL : 0UL;
return ::ioctlsocket(handle, FIONBIO, &mode) != SOCKET_ERROR;
}

handle_t rawAccept(handle_t listener) { return ::accept(listener, nullptr, nullptr); }
handle_t rawAccept(handle_t listener) {
return ::accept(listener, nullptr, nullptr);
}

// Winsock: the accepted socket already inherited the listener's blocking mode.
void rawAfterAccept(handle_t, handle_t) {}
Expand Down
4 changes: 2 additions & 2 deletions src/net/socket.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace net {
// failure; the reason is in lastError() (only when the failure came from the OS
// layer — calling on an invalid handle just returns false).
class Socket {
public:
public:
Socket() = default;
explicit Socket(detail::handle_t handle) : handle_(handle) {}
~Socket() { close(); }
Expand Down Expand Up @@ -52,7 +52,7 @@ class Socket {
// >0 bytes read, 0 peer closed, -1 error (WouldBlock possible on non-blocking).
int recvSome(void* buffer, std::size_t capacity);

private:
private:
static detail::handle_t detach(Socket& s) {
const detail::handle_t h = s.handle_;
s.handle_ = detail::kInvalidHandle;
Expand Down
Loading
Loading