first commit: echo server

This commit is contained in:
Antonio Cossari
2026-09-07 00:45:35 +02:00
commit b15ce130f9
47 changed files with 2614 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# Style contract for this repo. Taken from the CPP modules, so both codebases
# read the same way, with the Standard line corrected — see below.
# Google as the base (2-space indent, K&R braces, west-const pointers).
---
Language: Cpp
BasedOnStyle: Google
# `Standard` only tells clang-format how to *parse* the source; it has nothing to
# do with what the compiler is given. There is no `c++23` value — the enum stops
# at `c++20` plus `Latest` and `Auto` (verified against clang-format 18.1.3:
# c++23 rejected, c++20/Latest/Auto accepted), and an unknown value makes
# clang-format refuse the whole file with "unknown enumerated scalar", not fall
# back to a default. `Latest` is the value that means "the newest this binary
# knows". The language standard is set once, in the Makefile: `-std=c++23`.
Standard: Latest
# 80 columns, straight from the Google C++ Style Guide ("Line Length"). This is
# the dominant limit across C++ house styles: Google, LLVM, Chromium, Mozilla,
# Abseil, folly. The 100 used by Rust/Kotlin/Qt is a newer-language convention
# and does not carry over. Nothing to do with norminette, which is a C rule.
ColumnLimit: 80
IndentWidth: 2
# Access specifiers get their own indent level: `public:` at 2, members at 4.
IndentAccessModifiers: true
# `T& name` / `T* name` — west style.
PointerAlignment: Left
ReferenceAlignment: Pointer
# One-liner bodies allowed only for functions defined inside a class — trivial
# accessors in headers. An out-of-line definition always gets a real body.
# (The CPP modules use None; Google and LLVM both default to All. Inline is the
# middle ground, and the only part of this file that deviates from them.)
AllowShortFunctionsOnASingleLine: Inline
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
# Include order is deliberate (own header first, then std by topic) — don't shuffle.
SortIncludes: false
+96
View File
@@ -0,0 +1,96 @@
# Quality contract for this repo, machine-readable half. ".clang-format" decides
# how the code is laid out; this file decides what it is allowed to say. The two
# are meant to agree: FormatStyle below points clang-tidy at ".clang-format", so
# "--fix" never reformats against the house style.
#
# Run it with "make tidy" — a separate target, not part of "make", and it does
# not fail the build (WarningsAsErrors is empty). The compile flags reach
# clang-tidy after the "--", which is why no compile_commands.json is needed.
---
Checks: >
bugprone-*,
performance-*,
modernize-*,
portability-*,
clang-analyzer-*,
misc-definitions-in-headers,
misc-misplaced-const,
misc-unused-parameters,
misc-unused-using-decls,
readability-identifier-naming,
readability-redundant-*,
readability-container-size-empty,
readability-misleading-indentation,
readability-simplify-boolean-expr,
readability-string-compare,
-bugprone-easily-swappable-parameters,
-modernize-use-trailing-return-type,
-modernize-use-nodiscard,
-performance-enum-size
# Why those four are off:
# bugprone-easily-swappable-parameters — fires on any two adjacent parameters
# of the same type, so every "(int fd, int events)" in a network codebase.
# modernize-use-trailing-return-type — would rewrite every signature as
# "auto f() -> T", which contradicts the Google base of ".clang-format".
# modernize-use-nodiscard — wants [[nodiscard]] on essentially every const
# accessor; the contract puts it where it earns its place, on Result<T>.
# performance-enum-size — suggests a narrower underlying type for every enum;
# pure churn on enums that hold a handful of values.
# Only our own headers. System headers are excluded by clang-tidy anyway unless
# -system-headers is passed, and standard library headers have no extension, so
# this pattern cannot reach them.
HeaderFilterRegex: '.*\.(hpp|tpp|ipp)$'
# Empty on purpose: "make tidy" reports, it does not block. A single overzealous
# check must never be able to stop the build.
WarningsAsErrors: ''
FormatStyle: file
CheckOptions:
# Types.
readability-identifier-naming.ClassCase: CamelCase
readability-identifier-naming.StructCase: CamelCase
readability-identifier-naming.UnionCase: CamelCase
readability-identifier-naming.EnumCase: CamelCase
readability-identifier-naming.TypeAliasCase: CamelCase
readability-identifier-naming.TypedefCase: CamelCase
readability-identifier-naming.TemplateParameterCase: CamelCase
# Enumerators follow the type: EndpointKind::Listener, ListenerPause::PoolFull.
readability-identifier-naming.EnumConstantCase: CamelCase
readability-identifier-naming.NamespaceCase: lower_case
# Functions and variables.
readability-identifier-naming.FunctionCase: camelBack
readability-identifier-naming.MethodCase: camelBack
readability-identifier-naming.VariableCase: camelBack
readability-identifier-naming.ParameterCase: camelBack
# Data members: trailing underscore on the ones that are not public. A public
# member belongs to a plain aggregate (Header{name, value}) and takes none.
readability-identifier-naming.MemberCase: camelBack
readability-identifier-naming.PublicMemberCase: camelBack
readability-identifier-naming.PrivateMemberCase: camelBack
readability-identifier-naming.PrivateMemberSuffix: _
readability-identifier-naming.ProtectedMemberCase: camelBack
readability-identifier-naming.ProtectedMemberSuffix: _
# Constants are UPPER_SNAKE_CASE only where they are constants in the sense
# that matters — namespace scope, static, or a class-level constant such as
# ByteBuffer::CAPACITY. ConstexprVariableCase and LocalConstantCase are left
# unset deliberately: clang-tidy only uses a style kind it has been given a
# value for, so an unset one falls through to the next candidate, and a
# "constexpr" inside a function lands on VariableCase and stays camelBack.
readability-identifier-naming.GlobalConstantCase: UPPER_CASE
readability-identifier-naming.StaticConstantCase: UPPER_CASE
readability-identifier-naming.ClassConstantCase: UPPER_CASE
readability-identifier-naming.MacroDefinitionCase: UPPER_CASE
# "_foo" at namespace scope, and anything with "__", is reserved to the
# implementation. This is the check that keeps the trailing-underscore rule
# from drifting back to a leading one.
bugprone-reserved-identifier.AllowedIdentifiers: ''
+32
View File
@@ -0,0 +1,32 @@
{
"name": "webserv (Ubuntu 22.04 / g++ 14)",
// Reuses the same compose file the terminal workflow uses, so the editor and
// `docker compose run` always land in an identical environment.
"dockerComposeFile": "../docker/compose.yml",
"service": "dev",
"workspaceFolder": "/webserv",
"remoteUser": "dev",
// Keep the container alive when the VS Code window is closed, instead of
// tearing it down and rebuilding on the next open.
"shutdownAction": "none",
"customizations": {
"vscode": {
"extensions": [
"ms-vscode.cpptools",
"ms-vscode.makefile-tools",
"twxs.cmake"
],
"settings": {
"C_Cpp.default.compilerPath": "/usr/bin/g++-14",
"C_Cpp.default.cppStandard": "c++23",
"C_Cpp.default.intelliSenseMode": "linux-gcc-x64",
"editor.formatOnSave": false,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
# Build output
obj/
*.o
*.d
webserv
webserv-clang
# Container user ids, taken from the machine the image is built on
docker/.env
# Runtime artifacts
*.log
www/uploads/
# macOS
.DS_Store
+105
View File
@@ -0,0 +1,105 @@
# **************************************************************************** #
# #
# ::: :::::::: #
# Makefile :+: :+: :+: #
# +:+ +:+ +:+ #
# By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ #
# +#+#+#+#+#+ +#+ #
# Created: 2026/08/19 10:05:52 by acossari #+# #+# #
# Updated: 2026/08/20 22:18:47 by acossari ### ########.fr #
# #
# **************************************************************************** #
NAME := webserv
SRC_DIR := src
OBJ_DIR := obj
INC_DIR := include
CXX := g++
# EXTRA_CXXFLAGS is the slot for flags added from the command line, for example
# make EXTRA_CXXFLAGS="-g3 -fsanitize=address". CXXFLAGS itself must never be
# overridden that way, it carries the warning flags that have to stay on.
CXXFLAGS := -Wall -Wextra -Werror -std=c++23 -I$(INC_DIR) $(EXTRA_CXXFLAGS)
# The C++ standard library goes inside the binary instead of being loaded from
# the system at startup. A recent compiler emits references to symbol versions
# that an older system libstdc++.so.6 does not carry, and that failure shows up
# when the program is started, not when it is linked.
LDFLAGS := -static-libstdc++ -static-libgcc
CLANG ?= clang++
CLANG_TIDY ?= clang-tidy
CLANG_FORMAT ?= clang-format
# Separate object directory and binary for the clang build. Sharing obj/ would
# link a fresh g++ object with a leftover clang one, and the error looks like a
# bug in the source instead of an old file.
CLANG_NAME := $(NAME)-clang
CLANG_OBJ_DIR := $(OBJ_DIR)/clang
# libstdc++ is the GNU library and ships with g++, libc++ is the LLVM one and
# ships with clang. On Linux clang usually sits on libstdc++ anyway, and that
# pairing cannot build this project:
#
# clang 18 with libstdc++ : std::expected not available
# clang 18 with libc++ : std::expected available
#
# So every clang pass gets libc++. g++ never sees this flag.
CLANG_STDLIB ?= -stdlib=libc++
# find returns files in filesystem order, which is not the same on every
# machine. sort keeps the compile and link lines stable, so build logs and
# clang-tidy output stay comparable.
SRCS := $(sort $(shell find $(SRC_DIR) -type f -name '*.cpp'))
OBJS := $(SRCS:$(SRC_DIR)/%.cpp=$(OBJ_DIR)/%.o)
# Make only knows that obj/main.o comes from src/main.cpp, so editing Log.hpp
# would leave the old main.o in place. Each .d adds the missing line, obj/main.o
# also depends on include/Log.hpp, and -include at the bottom pulls them in.
DEPS := $(OBJS:.o=.d)
HDRS := $(sort $(shell find $(INC_DIR) $(SRC_DIR) -type f \
\( -name '*.hpp' -o -name '*.tpp' \)))
all: $(NAME)
$(NAME): $(OBJS)
$(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) -o $(NAME)
# -MMD writes the .d file described above. -MP adds an empty rule for every
# header in it, which matters after a rename: the old .d still asks for the old
# path and make stops, having no way to build that file. The empty rule says the
# header needs no recipe, so make moves on and recompiles.
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.cpp
@mkdir -p $(dir $@)
$(CXX) $(CXXFLAGS) -MMD -MP -c $< -o $@
# Reports only, never fails the build: WarningsAsErrors is empty in .clang-tidy
# and all does not depend on this. clang-tidy parses the code itself, so it
# needs the same flags the compiler gets (everything after the --).
tidy: $(SRCS)
$(CLANG_TIDY) $(SRCS) -- $(CXXFLAGS) $(CLANG_STDLIB)
# Same sources through the other compiler and standard library, into files of
# its own, so the normal build is untouched. Worth running because the two
# compilers do not warn about the same things. Needs libc++ 18 or newer, the
# first release that ships <print>.
check-clang:
$(MAKE) --no-print-directory NAME=$(CLANG_NAME) OBJ_DIR=$(CLANG_OBJ_DIR) \
CXX=$(CLANG) EXTRA_CXXFLAGS="$(CLANG_STDLIB)"
format:
$(CLANG_FORMAT) -i $(SRCS) $(HDRS)
format-check:
$(CLANG_FORMAT) --dry-run --Werror $(SRCS) $(HDRS)
clean:
rm -rf $(OBJ_DIR)
fclean: clean
rm -f $(NAME) $(CLANG_NAME)
re: fclean all
-include $(DEPS)
.PHONY: all clean fclean re tidy check-clang format format-check
+52
View File
@@ -0,0 +1,52 @@
# Default configuration loaded when no configuration path is provided.
server {
listen 0.0.0.0:8000;
server_name localhost;
root ./www;
index index.html;
client_max_body_size 1m;
error_page 404 /errors/404.html;
error_page 500 502 503 504 /errors/50x.html;
location / {
allow_methods GET;
}
location /files {
allow_methods GET DELETE;
autoindex on;
}
location /upload {
alias ./www/uploads;
allow_methods GET POST DELETE;
upload_store ./www/uploads;
client_max_body_size 10m;
}
location /old {
return 301 /;
}
}
# Both server blocks share one listening socket because they use the same
# endpoint. The Host header selects a block, and the first one is used when no
# server name matches.
#
# A browser needs the "127.0.0.1 webserv.local" entry in /etc/hosts.
# curl can supply that mapping for one request:
# curl --resolve webserv.local:8000:127.0.0.1 http://webserv.local:8000/
server {
listen 0.0.0.0:8000;
server_name webserv.local;
root ./www/vhost;
index index.html;
location / {
allow_methods GET;
}
}
+58
View File
@@ -0,0 +1,58 @@
# Configuration for the official 42 tester, tester42/official/tester.
#
# Every path below is relative to the repository root, so run both from there:
# ./webserv conf/tester.conf
# ./tester42/official/tester http://localhost:8000
server {
# 0.0.0.0 accepts connections arriving on any interface. 127.0.0.1 would
# accept only those from the same machine.
listen 0.0.0.0:8000;
server_name webserv.tester;
# The tester POSTs a 100 MB body, so the ceiling here has to sit above it.
# /post_body lowers it again for that one location.
client_max_body_size 200m;
root ./tester42/YoupiBanane;
index index.html;
# GET only, so that POST and HEAD on / answer 405.
location / {
allow_methods GET;
}
# alias and not root, and this is the case that shows they differ. The
# server root is already YoupiBanane, so root would map /directory/nop onto
# YoupiBanane/directory/nop, which does not exist. alias replaces the
# matched prefix instead of keeping it.
#
# index youpi.bad_extension makes GET /directory and GET /directory/nop
# succeed while GET /directory/Yeah gives 404, since Yeah is the one
# subdirectory without that file. autoindex must stay off, or the listing
# would answer 200 there instead.
location /directory {
alias ./tester42/YoupiBanane;
allow_methods GET;
index youpi.bad_extension;
autoindex off;
}
# Matched by extension, and this match wins over the /directory prefix
# above, so POST /directory/youpi.bla reaches the CGI and not the static
# handler. GET is allowed as well because the same path is also requested
# with GET, and a location matched by extension sends every method to the
# CGI.
location ~ \.bla$ {
allow_methods GET POST;
cgi_pass ./tester42/official/cgi_tester;
}
# The limit is inclusive: a body of exactly 100 bytes succeeds, 101 gives
# 413. Bodies arrive chunked with no Content-Length, so the count is taken
# on decoded bytes as they stream in.
location /post_body {
allow_methods POST;
client_max_body_size 100;
}
}
+91
View File
@@ -0,0 +1,91 @@
# Development environment for webserv.
#
# Target platform is Linux, because that is what the Codam VMs run and what the
# official testers are built for. The base is Ubuntu 22.04, the same release
# those VMs run, so this image is that environment rather than something close
# to it. A newer base would be the permissive side of the difference: its glibc
# accepts everything the older one does and more, so code that cannot run at
# school would still build clean here. On 24.04 a plain std::strtoul compiles
# into a call to __isoc23_strtoul, a GLIBC_2.38 symbol that 22.04 does not have,
# at every -std= and not only at C++23.
#
# 22.04 ships neither compiler this project needs, so two repositories are
# added: ubuntu-toolchain-r/test for g++ 14, apt.llvm.org for clang 18. Both
# publish builds made for 22.04, which is what keeps the produced binary
# runnable there.
#
# libc++ is installed alongside libstdc++ for one specific reason: clang 18
# reports __cpp_concepts as 201907, and libstdc++ gates <expected> on 202002L,
# so clang + libstdc++ compiles "#include <expected>" into nothing at all. The
# control build with clang — a second opinion on warnings, and the sanitizers —
# would die on the first function returning Result<T>. "make check-clang" passes
# -stdlib=libc++ for exactly this.
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
gnupg \
wget \
lsb-release \
software-properties-common \
&& add-apt-repository -y ppa:ubuntu-toolchain-r/test \
&& wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key \
| gpg --dearmor -o /usr/share/keyrings/llvm-archive-keyring.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/llvm-archive-keyring.gpg]" \
"http://apt.llvm.org/jammy/ llvm-toolchain-jammy-18 main" \
> /etc/apt/sources.list.d/llvm.list \
&& apt-get update
RUN apt-get install -y --no-install-recommends \
build-essential \
g++-14 \
clang-18 \
clang-tidy-18 \
clang-format-18 \
libc++-18-dev \
libc++abi-18-dev \
libclang-rt-18-dev \
gdb \
valgrind \
strace \
make \
git \
curl \
siege \
python3 \
python3-pip \
net-tools \
iproute2 \
procps \
less \
vim \
&& rm -rf /var/lib/apt/lists/*
# Make the versioned tools the defaults, so "c++", "g++", "clang++",
# "clang-tidy" and "clang-format" all point at something that understands C++23.
# Without the last two the Makefile would have to hardcode Ubuntu's versioned
# names, which are wrong everywhere else — Codam included.
RUN update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-14 100 \
&& update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-14 100 \
&& update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-18 100 \
&& update-alternatives --install /usr/bin/c++ c++ /usr/bin/g++-14 100 \
&& update-alternatives --install /usr/bin/clang-tidy clang-tidy \
/usr/bin/clang-tidy-18 100 \
&& update-alternatives --install /usr/bin/clang-format clang-format \
/usr/bin/clang-format-18 100
# Run as a normal user whose uid/gid match the host account, so files created
# inside the container (object files, uploads, test output) do not end up owned
# by root on the macOS side of the bind mount.
ARG UID=1000
ARG GID=1000
RUN if getent passwd ${UID} >/dev/null; then userdel -r "$(getent passwd ${UID} | cut -d: -f1)"; fi \
&& groupadd -g ${GID} dev 2>/dev/null || true \
&& useradd -m -u ${UID} -g ${GID} -s /bin/bash dev
USER dev
WORKDIR /webserv
CMD ["/bin/bash"]
+29
View File
@@ -0,0 +1,29 @@
services:
dev:
build:
context: .
dockerfile: Dockerfile
# Read from docker/.env, which is machine-specific and gitignored.
# The fallbacks are the usual first-user ids on Linux, so a fresh clone
# without a .env still builds correctly there.
args:
UID: "${UID:-1000}"
GID: "${GID:-1000}"
image: webserv-dev
container_name: webserv-dev
# The project directory lives on the Mac and is mounted here: you keep editing
# in VS Code on the host, while compiling and running on Linux.
volumes:
- ..:/webserv
working_dir: /webserv
# Ports the config files listen on, exposed to the Mac so a host browser,
# curl or siege can reach the server running inside.
ports:
- "8000:8000"
- "8001:8001"
- "8002:8002"
- "8080:8080"
# Keeps the container alive so you can `docker compose exec dev bash` into it.
stdin_open: true
tty: true
command: /bin/bash
+61
View File
@@ -0,0 +1,61 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Log.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/19 20:30:58 by acossari #+# #+# */
/* Updated: 2026/08/20 22:18:40 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <format>
#include <string_view>
// Free functions in a namespace, not a class: the whole process shares one
// verbosity level and one destination, so a second instance would have nothing
// of its own to hold.
namespace webserv::log {
// The "class" keyword here is overloaded: not a class but a scoped enum.
// Names must be written Level::Info, plus no implicit conversion to int.
enum class Level { Debug, Info, Warn, Error };
// Thin Template Idiom: vlog keeps one ordinary body instead of repeating it
// for every argument type combination. The small wrappers retain compile time
// checks, erase those types and converge here at the shared sink. Its v follows
// the C variadic convention. The sink filters by level before formatting.
void vlog(Level messageLevel, std::string_view formatString,
std::format_args args);
// format_string uses Args to check at compile time that every placeholder
// receives a value of the expected type. Args&& is a "forwarding reference",
// so the same wrapper accepts lvalues and rvalues, as in info("{}", value)
// and info("{}", 42), without copying them.
template <typename... Args>
void debug(std::format_string<Args...> formatString, Args&&... args) {
// make_format_args packs the arguments together with their type information.
// vlog then sees only std::format_args and does not need a separate template
// version for every combination of argument types.
vlog(Level::Debug, formatString.get(), std::make_format_args(args...));
}
template <typename... Args>
void info(std::format_string<Args...> formatString, Args&&... args) {
vlog(Level::Info, formatString.get(), std::make_format_args(args...));
}
template <typename... Args>
void warn(std::format_string<Args...> formatString, Args&&... args) {
vlog(Level::Warn, formatString.get(), std::make_format_args(args...));
}
template <typename... Args>
void error(std::format_string<Args...> formatString, Args&&... args) {
vlog(Level::Error, formatString.get(), std::make_format_args(args...));
}
} // namespace webserv::log
+27
View File
@@ -0,0 +1,27 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Result.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/20 20:02:19 by acossari #+# #+# */
/* Updated: 2026/08/21 20:02:26 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <expected>
#include "http/HttpStatus.hpp"
namespace webserv {
// Discarding a Result can hide a request error. Neither this alias nor
// std::expected can enforce [[nodiscard]], so every function returning a
// Result must declare the attribute itself.
template <typename T>
using Result = std::expected<T, HttpStatus>;
} // namespace webserv
+78
View File
@@ -0,0 +1,78 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Server.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/09/01 09:13:02 by acossari #+# #+# */
/* Updated: 2026/09/01 19:00:54 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <sys/socket.h>
#include <chrono>
#include <cstdint>
#include <optional>
#include <span>
#include <vector>
#include "event/Epoll.hpp"
#include "net/Listener.hpp"
#include "net/PauseMask.hpp"
#include "net/SlotPool.hpp"
namespace webserv {
class Server final {
public:
// Both constants use direct-list-initialization to call the explicit
// duration constructor. A bare "= 500" would need an implicit conversion
// from int to std::chrono::milliseconds.
// After accept() fails, listeners are temporarily disarmed before
// retrying. Level-triggered epoll would otherwise keep
// reporting the ready listener and create a busy loop. The 500 ms delay
// matches nginx's default.
static constexpr std::chrono::milliseconds ACCEPT_BACKOFF{500};
// A rare, documented race exists if SIGINT arrives after the shutdown
// flag is checked but before epoll_wait begins. "epoll_pwait" closes it
// atomically, but the subject does not allow it. Bounding an idle wait lets
// run() recheck the flag within 500 ms. This interval is a trade-off, not a
// measured optimum, and causes at most two timeout wakeups per idle second.
static constexpr std::chrono::milliseconds MAX_IDLE_WAIT{500};
// TEMP
// Endpoints are hardcoded in main() until the config parser is available.
explicit Server(std::span<const sockaddr_storage> endpoints);
void run();
private:
void dispatch(std::uint64_t packedToken, std::uint32_t events);
void acceptFrom(std::uint32_t listenerIndex);
void handleAcceptFailure(std::uint32_t listenerIndex, int errnoCode);
void startBackoff();
void retire(std::uint32_t slotIndex) noexcept;
void stopListener(std::uint32_t listenerIndex) noexcept;
void updateRegistration(std::uint32_t slotIndex, std::uint32_t events);
void pause(ListenerPause reason);
void resume(ListenerPause reason);
void applyListenerEvents(std::uint32_t events);
void expireBackoff();
int millisUntilDeadline() const;
Epoll epoll_;
std::vector<Listener> listeners_;
SlotPool slotPool_;
PauseMask pauseMask_;
// Stores a fixed deadline because loop iterations have no fixed duration.
// Empty means no resource backoff is scheduled.
std::optional<std::chrono::steady_clock::time_point> backoffExpiry_;
};
} // namespace webserv
+57
View File
@@ -0,0 +1,57 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Epoll.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/26 11:34:23 by acossari #+# #+# */
/* Updated: 2026/08/26 11:34:25 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <sys/epoll.h>
#include <array>
#include <cstddef>
#include <cstdint>
#include <exception>
#include <span>
#include <string>
#include "net/FileDescriptor.hpp"
namespace webserv {
class Epoll final {
public:
class Error : public std::exception {
public:
explicit Error(std::string message);
const char* what() const noexcept override;
private:
std::string msg_;
};
// wait() returns at most this many ready events per call. Smaller batches
// require more calls, while larger batches delay the next loop iteration.
// The value is a middle ground. Excess events remain for later calls.
static constexpr std::size_t EVENT_BATCH_CAPACITY = 256;
Epoll();
void add(int fd, std::uint32_t events, std::uint64_t token);
void modify(int fd, std::uint32_t events, std::uint64_t token);
void remove(int fd) noexcept;
std::span<const epoll_event> wait(int timeoutMillis);
private:
FileDescriptor fd_;
std::array<epoll_event, EVENT_BATCH_CAPACITY> events_{};
};
} // namespace webserv
+56
View File
@@ -0,0 +1,56 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* EventToken.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/25 13:26:42 by acossari #+# #+# */
/* Updated: 2026/08/25 19:06:13 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <cstdint>
namespace webserv {
enum class EndpointKind {
ClientSocket,
CgiStdin,
CgiStdout,
Listener,
};
// Identifies which registered endpoint produced an epoll event without storing
// a pointer. For Listener, index selects a listener; for every other kind it
// selects a connection slot, while kind identifies its client socket or CGI
// pipe. epoll_wait() returns a batch in which an old event may remain after its
// endpoint has been retired and re-registered. generation distinguishes that
// old registration from the current one even when index and kind are equal.
//
// Packed layout in epoll_event.data.u64:
// bits 0-29 | index | 30 bits
// bits 30-31 | kind | 2 bits
// bits 32-63 | generation | 32 bits
struct EventToken {
static constexpr std::uint64_t INDEX_BITS = 30;
static constexpr std::uint64_t KIND_BITS = 2;
// List-initialization gives 1 the exact std::uint32_t type before shifting.
// std::uint32_t{1} << INDEX_BITS = 1,073,741,824
// (std::uint32_t{1} << INDEX_BITS) - 1 = 1,073,741,823
// Binary: 100...000 (30 zeros) - 1 = 011...111 (30 ones).
static constexpr std::uint32_t MAX_INDEX =
(std::uint32_t{1} << INDEX_BITS) - 1;
std::uint32_t index;
EndpointKind kind;
std::uint32_t generation;
};
std::uint64_t pack(EventToken token);
EventToken unpack(std::uint64_t token);
} // namespace webserv
+50
View File
@@ -0,0 +1,50 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* HttpStatus.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/20 19:29:20 by acossari #+# #+# */
/* Updated: 2026/08/21 19:32:48 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
namespace webserv {
enum class HttpStatus {
// Success
Ok = 200,
Created = 201,
NoContent = 204,
// Redirection
MovedPermanently = 301,
Found = 302,
SeeOther = 303,
TemporaryRedirect = 307,
PermanentRedirect = 308,
// Client errors
BadRequest = 400,
Forbidden = 403,
NotFound = 404,
MethodNotAllowed = 405,
RequestTimeout = 408,
LengthRequired = 411,
ContentTooLarge = 413,
UriTooLong = 414,
RequestHeaderFieldsTooLarge = 431,
// Server errors
InternalServerError = 500,
NotImplemented = 501,
BadGateway = 502,
ServiceUnavailable = 503,
GatewayTimeout = 504,
HttpVersionNotSupported = 505,
};
} // namespace webserv
+61
View File
@@ -0,0 +1,61 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* ByteBuffer.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/25 09:30:45 by acossari #+# #+# */
/* Updated: 2026/08/25 13:26:00 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <cstddef>
#include <span>
#include <vector>
namespace webserv {
class ByteBuffer final {
public:
// The fixed 64 KiB capacity is also the maximum request-head size. It
// leaves room for large headers while keeping memory bounded. The server
// accepts at most 256 simultaneous connections, each with one receive and
// one send buffer. In the worst-case scenario, their storage is 32 MiB, a
// modest fixed ceiling for all connection buffers combined.
// The C++23 uz suffix makes 64 a std::size_t, so multiplication occurs in
// std::size_t instead of int and avoids implicit widening (clang-tidy).
static constexpr std::size_t CAPACITY = 64uz * 1024;
std::span<const char> readable() const {
return {storage_.data() + readPos_, writePos_ - readPos_};
}
std::span<char> writableTail() {
return {storage_.data() + writePos_, CAPACITY - writePos_};
}
// Makes count bytes already written in writableTail() readable. count must
// not exceed writableTail().size().
void commit(std::size_t count);
// Discards count bytes from the start of readable(). count must not exceed
// readable().size().
void consume(std::size_t count);
void compact();
private:
// C++17 guaranteed copy elision constructs this vector directly in
// storage_, with no temporary vector or move.
std::vector<char> storage_ = std::vector<char>(CAPACITY);
// Only bytes from readPos_ up to, but not including, writePos_ are live.
// Values outside this range can remain in storage_ and are ignored.
std::size_t readPos_ = 0;
std::size_t writePos_ = 0;
};
} // namespace webserv
+71
View File
@@ -0,0 +1,71 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Connection.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/28 09:21:04 by acossari #+# #+# */
/* Updated: 2026/08/29 18:07:36 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <cstddef>
#include <cstdint>
#include "net/ByteBuffer.hpp"
#include "net/FileDescriptor.hpp"
namespace webserv {
class Connection final {
public:
enum class Lifecycle { Alive, Done, Fatal };
struct Outcome {
Lifecycle lifecycle;
std::uint32_t desiredEvents;
};
// ByteBuffer::CAPACITY is 64 KiB.
// At most 16 KiB are copied per 48 KiB appended: 16 / 48 = 1 / 3.
// A lower threshold reduces copying but leaves less unread work available.
// The 16 KiB value is a fixed compromise, not a measured optimum.
static constexpr std::size_t LOW_WATERMARK = 16uz * 1024;
explicit Connection(FileDescriptor socket);
// Each Connection has a unique identity and remains in its original slot.
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
Connection(Connection&&) = delete;
Connection& operator=(Connection&&) = delete;
int fd() const noexcept { return socket_.get(); }
Outcome onClientEvent(std::uint32_t events);
std::uint32_t desiredEvents() noexcept;
void close() noexcept { socket_.reset(); }
private:
// recv() returning zero means the client has finished sending data.
// readClosed_ records this. receive() still returns true because this
// is not an error and the socket may still send its buffered response.
// Only a recv() error returns false.
bool receive();
bool transmit();
// TEMP echo path: queues received bytes unchanged for sending back to
// the client.
void forwardReceivedBytes();
FileDescriptor socket_;
ByteBuffer receiveBuffer_;
ByteBuffer sendBuffer_;
bool readClosed_ = false;
};
} // namespace webserv
+61
View File
@@ -0,0 +1,61 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* FileDescriptor.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/24 16:24:03 by acossari #+# #+# */
/* Updated: 2026/08/24 20:05:59 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <exception>
#include <string>
namespace webserv {
// Sole owner of one file descriptor, move-only. Copying would give two owners
// the same number, and the second close would land on whatever the kernel had
// handed out in the meantime, killing an unrelated connection.
class FileDescriptor final {
public:
class Error : public std::exception {
public:
explicit Error(std::string message);
const char* what() const noexcept override;
private:
std::string msg_;
};
FileDescriptor() = default;
// Takes ownership and sets FD_CLOEXEC, so a successful execve() in a
// forked child closes the descriptor before the new program starts. A
// negative fd is accepted and gives an invalid object instead of throwing,
// so the result of socket() can be passed in and checked with valid().
explicit FileDescriptor(int fd);
~FileDescriptor();
FileDescriptor(const FileDescriptor&) = delete;
FileDescriptor& operator=(const FileDescriptor&) = delete;
FileDescriptor(FileDescriptor&& other) noexcept;
FileDescriptor& operator=(FileDescriptor&& other) noexcept;
int get() const noexcept { return fd_; }
bool valid() const noexcept { return fd_ >= 0; }
void setNonBlocking();
// Closes now instead of at destruction. Idempotent.
void reset() noexcept;
private:
int fd_ = -1;
};
} // namespace webserv
+48
View File
@@ -0,0 +1,48 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Listener.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/27 15:52:02 by acossari #+# #+# */
/* Updated: 2026/09/01 18:10:13 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <sys/socket.h>
#include <exception>
#include <string>
#include "net/FileDescriptor.hpp"
namespace webserv {
class Listener final {
public:
class Error : public std::exception {
public:
explicit Error(std::string message);
const char* what() const noexcept override;
private:
std::string msg_;
};
explicit Listener(const sockaddr_storage& address);
int fd() const noexcept { return fd_.get(); }
// Delegates the validity check without exposing the owned FileDescriptor.
bool valid() const noexcept { return fd_.valid(); }
void close() noexcept { fd_.reset(); }
private:
FileDescriptor fd_;
};
} // namespace webserv
+37
View File
@@ -0,0 +1,37 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* PauseMask.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/25 20:00:34 by acossari #+# #+# */
/* Updated: 2026/08/25 20:00:38 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <cstdint>
namespace webserv {
// PauseMask stores its flags in an 8-bit integer. Using the same type here
// makes the compiler reject a ninth flag, which would not fit in the mask.
enum class ListenerPause : std::uint8_t {
PoolFull = 1U << 0,
AcceptBackoff = 1U << 1,
};
class PauseMask final {
public:
// set() returns true when the first reason is added; clear() returns true
// when the last is removed. Those transitions disarm and rearm listeners.
[[nodiscard]] bool set(ListenerPause reason);
[[nodiscard]] bool clear(ListenerPause reason);
private:
std::uint8_t bits_ = 0;
};
} // namespace webserv
+104
View File
@@ -0,0 +1,104 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* SlotPool.hpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/28 21:51:32 by acossari #+# #+# */
/* Updated: 2026/08/29 20:33:13 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <vector>
#include "event/EventToken.hpp"
#include "net/Connection.hpp"
#include "net/FileDescriptor.hpp"
namespace webserv {
// SlotPool uses fixed storage instead of growing a container for every new
// connection. This representation provides three guarantees:
//
// 1. Every slot keeps a permanent index and stable storage. EventToken pairs
// that index with an endpoint generation so stale events can be rejected.
//
// 2. The slot array is allocated once. Connections are constructed and
// destroyed in place, so their slots are never relocated.
//
// 3. The free list limits the pool to CAPACITY connections. acquire() returns
// no index when it is exhausted, and drain() makes retired slots reusable.
class SlotPool final {
public:
// Slot indices use uint32_t to match their field in the 64-bit EventToken.
// 256 is a calculated tradeoff, not an optimal value. It doubles the
// target of 128 concurrent clients exercised by the provided tester. It
// assumes a conservative limit of 1024 fds per process, common on Linux.
//
// fd budget = 4 base fds (standard streams and epoll)
// + L listener fds
// + 2 * 256 connection fds (socket + possible temporary file)
// + 2 * 24 CGI pipe fds (the tester reaches 20, leaving 4 spare)
// + 8 spare fds (temporary setup and general working margin)
// = 572 + L
//
// This leaves space for up to 452 listeners, giving substantial headroom.
// A capacity of 512 would require 1084 + L fds, already exceeding 1024.
static constexpr std::uint32_t CAPACITY = 256;
// Each slot tracks the client socket and the CGI stdin and stdout pipes.
static constexpr std::size_t ENDPOINTS_PER_SLOT = 3;
struct Endpoint {
std::uint32_t generation = 0;
std::uint32_t registeredEvents = 0;
bool registered = false;
};
SlotPool();
bool full() const noexcept { return freeHead_ == CAPACITY; }
// Constructs a Connection for the socket in a free slot and returns its
// index. Returns std::nullopt when the pool is full.
std::optional<std::uint32_t> acquire(FileDescriptor socket);
// Returns true only if the token still identifies a registered endpoint
// in an active slot with the same generation.
bool isCurrent(const EventToken& token) const noexcept;
// Accesses the connection or endpoint metadata already stored in a slot.
Connection& connection(std::uint32_t index);
Endpoint& endpoint(std::uint32_t index, EndpointKind kind);
// Quarantines an active slot until the current event batch is complete.
void retire(std::uint32_t index) noexcept;
// Recycles all quarantined slots and returns how many became available.
std::size_t drain();
private:
enum class SlotState { Free, InUse, Retired };
struct Slot {
std::array<Endpoint, ENDPOINTS_PER_SLOT> endpoints;
std::optional<Connection> connection;
SlotState state = SlotState::Free;
std::uint32_t nextFree = 0;
};
std::array<Slot, CAPACITY> slots_;
// Holds retired slot indices until they can return to the free list.
// With storage reserved for CAPACITY entries, it acts as if it were a fixed
// array while tracking how many entries are active without another counter.
std::vector<std::uint32_t> pendingFree_;
std::uint32_t freeHead_ = 0;
};
} // namespace webserv
+75
View File
@@ -0,0 +1,75 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Log.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/20 16:55:22 by acossari #+# #+# */
/* Updated: 2026/08/21 17:04:33 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "Log.hpp"
#include <chrono>
#include <cstdio>
#include <print>
namespace webserv::log {
// Anonymous namespace: nothing in here is visible outside this file. Writing
// "static" on each name does the same, this keeps the rule in one place. The
// constant below would be local anyway, constexpr already implies it.
namespace {
// Verbosity floor: vlog drops anything below it. To quiet a noisy run, edit
// this line and rebuild.
constexpr Level ACTIVE_LEVEL = Level::Info;
constexpr std::string_view tag(Level level) {
switch (level) {
case Level::Debug:
return "DEBUG";
case Level::Info:
return "INFO";
case Level::Warn:
return "WARN";
case Level::Error:
return "ERROR";
}
return "?";
}
} // namespace
void vlog(Level messageLevel, std::string_view formatString,
std::format_args args) {
if (messageLevel < ACTIVE_LEVEL) {
return;
}
const auto now = std::chrono::floor<std::chrono::seconds>(
std::chrono::system_clock::now());
// Printing can throw, both while building the string and while writing it.
// A logger that takes down its caller is worse than one that misses a line,
// so the failure stops here.
try {
// Format specifiers:
// %F date, 2026-08-21
// %T time, 12:58:41
// %Z zone name, UTC because now comes from the system clock
// <5 left aligned in five columns, so the level tags stay lined up
std::print(stderr, "[{:%F %T %Z}] [{:<5}] {}\n", now, tag(messageLevel),
// vformat replaces the placeholders in formatString with the
// values stored in args and returns the completed message.
std::vformat(formatString, args));
// "Exception swallowing": there is nowhere left to report a logging
// failure to, since the reporting channel is precisely what just failed.
// NOLINTNEXTLINE(bugprone-empty-catch)
} catch (...) {
}
}
} // namespace webserv::log
+389
View File
@@ -0,0 +1,389 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Server.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/09/01 19:00:50 by acossari #+# #+# */
/* Updated: 2026/09/04 07:58:42 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "Server.hpp"
#include <sys/epoll.h>
#include <algorithm>
#include <array>
#include <cassert>
#include <cerrno>
#include <csignal>
#include <cstring>
#include <exception>
#include <utility>
#include "Log.hpp"
#include "event/EventToken.hpp"
namespace webserv {
namespace {
// volatile makes the loop read this variable again at every check instead of
// reusing an earlier value. sig_atomic_t makes each simple read or write
// indivisible when a signal handler interrupts normal execution.
volatile std::sig_atomic_t stopRequested = 0;
// signal() passes the signal number to a void(int) handler. On Linux, SIGINT
// is 2 and SIGTERM is 15. Leaving the unused int unnamed avoids a
// -Werror=unused-parameter compilation error.
void requestStop(int) {
stopRequested = 1;
}
std::uint64_t listenerToken(std::uint32_t listenerIndex) {
// Designated aggregate initialization creates an EventToken prvalue. It
// directly initializes the parameter of pack(), so no copy or move occurs.
return pack(EventToken{
.index = listenerIndex,
.kind = EndpointKind::Listener,
.generation = 0,
});
}
std::uint64_t clientToken(std::uint32_t slotIndex, std::uint32_t generation) {
return pack(EventToken{
.index = slotIndex,
.kind = EndpointKind::ClientSocket,
.generation = generation,
});
}
// Linux accept(2) documents the failures classified by these helpers. The
// groups distinguish a failed connection attempt, temporary resource
// exhaustion, and a listener or argument that is no longer valid.
// These failures concern one connection attempt. They do not invalidate the
// listener, so a later connection may still be accepted.
bool isTransientAcceptFailure(int errnoCode) {
constexpr std::array codes{
ECONNABORTED, EPERM, EPROTO, ENETDOWN, ENETUNREACH,
ENOPROTOOPT, EHOSTDOWN, EHOSTUNREACH, ENONET, EOPNOTSUPP,
ETIMEDOUT, ECONNRESET, ESOCKTNOSUPPORT, EPROTONOSUPPORT};
return std::ranges::contains(codes, errnoCode);
}
// These failures report exhausted process or kernel resources. They do not
// invalidate the listener and may clear before a later accept attempt.
bool isResourceFailure(int errnoCode) {
constexpr std::array codes{EMFILE, ENFILE, ENOBUFS, ENOMEM, ENOSR};
return std::ranges::contains(codes, errnoCode);
}
// These failures report an invalid descriptor, listener state, or address
// argument. Retrying the unchanged call cannot restore a valid accept state.
bool isBrokenListenerFailure(int errnoCode) {
constexpr std::array codes{EBADF, EFAULT, EINVAL, ENOTSOCK};
return std::ranges::contains(codes, errnoCode);
}
} // namespace
Server::Server(std::span<const sockaddr_storage> endpoints) {
listeners_.reserve(endpoints.size());
for (const sockaddr_storage& address : endpoints) {
listeners_.emplace_back(address);
}
for (std::uint32_t listenerIndex = 0; listenerIndex < listeners_.size();
++listenerIndex) {
epoll_.add(listeners_[listenerIndex].fd(), EPOLLIN,
listenerToken(listenerIndex));
}
}
void Server::run() {
// SIGINT handles Ctrl+C. SIGTERM is the usual request sent by the kill
// command. Both stop the loop so shutdown code and destructors can run.
if (::signal(SIGINT, requestStop) == SIG_ERR) {
log::error("signal(SIGINT): {}", std::strerror(errno));
}
if (::signal(SIGTERM, requestStop) == SIG_ERR) {
log::error("signal(SIGTERM): {}", std::strerror(errno));
}
while (stopRequested == 0) {
// Use whichever comes first: the shutdown check or the resource retry.
const auto readyEvents = epoll_.wait(millisUntilDeadline());
// If backoffExpiry_ has been reached, clear AcceptBackoff. If PoolFull
// is still set, listeners remain disabled. Otherwise, rearm each valid
// listener with EPOLLIN so the server can accept connections again.
expireBackoff();
for (const auto& event : readyEvents) {
// data.u64 is the token stored when the descriptor was registered.
// events is the 32 bit mask reported by epoll, such as EPOLLIN
dispatch(event.data.u64, event.events);
}
// The batch is complete, so quarantined slots can return to the free list.
// Recovering any slot clears the PoolFull pause reason if it was active.
if (slotPool_.drain() > 0) {
resume(ListenerPause::PoolFull);
}
}
log::info("shutting down");
}
void Server::dispatch(std::uint64_t packedToken, std::uint32_t events) {
const EventToken token = unpack(packedToken);
// A listener event means a new client may be waiting. Accept one connection
// and initialize its socket and pool slot.
if (token.kind == EndpointKind::Listener) {
assert(token.index < listeners_.size());
// An earlier listener event may accept the final available client and fill
// the pool. The server then tries to disable every listener by setting its
// epoll event mask to zero. If updating this listener throws, the error
// handler closes it, but its event may already be in the current batch.
// Check the descriptor so that queued event is ignored after the close.
if (listeners_[token.index].valid()) {
acceptFrom(token.index);
}
return;
}
// epoll may return an event after its endpoint was retired or replaced.
// Ignore it unless its token still matches the current registration.
if (!slotPool_.isCurrent(token)) {
return;
}
try {
// Outcome reports whether the connection stays alive and which events
// epoll should monitor next.
const Connection::Outcome outcome =
slotPool_.connection(token.index).onClientEvent(events);
if (outcome.lifecycle == Connection::Lifecycle::Alive) {
// Processing may change the buffer state and therefore the readiness
// events needed for a live connection.
updateRegistration(token.index, outcome.desiredEvents);
return;
}
} catch (const std::exception& error) {
log::error("connection {}: {}", token.index, error.what());
}
// Done and Fatal both close the connection. A caught processing exception
// follows the same path. The slot remains quarantined until the current
// event batch ends, then becomes available for a new connection.
retire(token.index);
}
// Accepts one pending connection from the ready listener and obtains a socket
// dedicated to that client.
void Server::acceptFrom(std::uint32_t listenerIndex) {
// The nullptr arguments currently omit the peer address and its byte length.
const int clientFd =
::accept(listeners_[listenerIndex].fd(), nullptr, nullptr);
if (clientFd == -1) {
// The subject forbids errno only after read, recv, write, and send.
// accept errors distinguish normal retries from resource backoff.
handleAcceptFailure(listenerIndex, errno);
return;
}
// This state must remain visible after try ends so catch can inspect the
// slot acquisition result.
std::optional<std::uint32_t> slotIndex;
try {
FileDescriptor clientSocket(clientFd);
clientSocket.setNonBlocking();
// Transfers the accepted socket into a pool slot. On success the new
// Connection owns it and acquire returns the slot index. A full pool
// returns no index and closes the socket through its FileDescriptor.
slotIndex = slotPool_.acquire(std::move(clientSocket));
if (!slotIndex.has_value()) {
pause(ListenerPause::PoolFull);
return;
}
Connection& connection = slotPool_.connection(*slotIndex);
SlotPool::Endpoint& endpoint =
slotPool_.endpoint(*slotIndex, EndpointKind::ClientSocket);
const std::uint32_t events = connection.desiredEvents();
epoll_.add(connection.fd(), events,
clientToken(*slotIndex, endpoint.generation));
endpoint.registered = true;
endpoint.registeredEvents = events;
} catch (const std::exception& error) {
log::error("accept on listener {}: {}", listenerIndex, error.what());
// If epoll_.add() throws after acquire() succeeds, the stored index
// identifies the slot to retire. If setup fails before acquire() returns,
// the FileDescriptor that owns the client socket closes it through RAII.
if (slotIndex.has_value()) {
retire(*slotIndex);
}
return;
}
if (slotPool_.full()) {
pause(ListenerPause::PoolFull);
}
}
void Server::handleAcceptFailure(std::uint32_t listenerIndex, int errnoCode) {
// Case 1: Nonfatal accept failure
// A network error can empty the accept queue after epoll reports readiness,
// so nonblocking accept() returns EAGAIN. The other failures concern the
// connection attempt, not the listener, so leave the listener registered.
if (errnoCode == EAGAIN || isTransientAcceptFailure(errnoCode)) {
log::debug("accept: {}", std::strerror(errnoCode));
return;
}
// Case 2: Temporary resource exhaustion
// Stop accepting new clients temporarily and wait before retrying.
if (isResourceFailure(errnoCode)) {
log::warn("accept: {}", std::strerror(errnoCode));
startBackoff();
return;
}
log::error("accept on listener {}: {}", listenerIndex,
std::strerror(errnoCode));
// Case 3: Known invariant violation
// An internal server error broke a condition accept() always requires: a
// valid listening socket and valid arguments. Retrying cannot change that
// state, so remove the listener instead of repeating the failure forever.
if (isBrokenListenerFailure(errnoCode)) {
stopListener(listenerIndex);
return;
}
// Case 4: Unrecognized accept failure
// Preserve the listener because the error does not prove it is unusable.
// Timed backoff prevents a busy loop, but a persistent failure can keep
// producing one error log for every retry indefinitely.
startBackoff();
}
// Removes the client socket from epoll when registered, then asks the pool to
// close the Connection and quarantine its slot until the event batch ends.
void Server::retire(std::uint32_t slotIndex) noexcept {
if (slotPool_.endpoint(slotIndex, EndpointKind::ClientSocket).registered) {
epoll_.remove(slotPool_.connection(slotIndex).fd());
}
slotPool_.retire(slotIndex);
}
void Server::stopListener(std::uint32_t listenerIndex) noexcept {
Listener& listener = listeners_[listenerIndex];
epoll_.remove(listener.fd());
listener.close();
log::error("listener {} left the event loop", listenerIndex);
// std::ranges::any_of scans the whole container and stops at the first true.
// The pointer to member function Listener::valid is called on each Listener,
// so anyLeft reports whether at least one valid listener remains.
const bool anyLeft = std::ranges::any_of(listeners_, &Listener::valid);
// No shutdown is requested when the final listener stops. Existing clients
// remain active until completion. The process then stays idle without
// accepting new connections and keeps running until a shutdown signal.
if (!anyLeft) {
log::error(
"no listener left: open connections are served to the end, no new "
"connection is accepted");
}
}
void Server::startBackoff() {
pause(ListenerPause::AcceptBackoff);
backoffExpiry_ = std::chrono::steady_clock::now() + ACCEPT_BACKOFF;
}
// Modifies a registered client socket only when its desired epoll events have
// changed. The cached mask is updated only after epoll accepts the change.
void Server::updateRegistration(std::uint32_t slotIndex, std::uint32_t events) {
SlotPool::Endpoint& endpoint =
slotPool_.endpoint(slotIndex, EndpointKind::ClientSocket);
assert(endpoint.registered);
if (endpoint.registeredEvents == events) {
return;
}
epoll_.modify(slotPool_.connection(slotIndex).fd(), events,
clientToken(slotIndex, endpoint.generation));
endpoint.registeredEvents = events;
}
// The first pause reason disarms listeners. Removing the last reason rearms
// them, preventing one cleared reason from resuming listeners too early.
void Server::pause(ListenerPause reason) {
if (pauseMask_.set(reason)) {
applyListenerEvents(0);
}
}
void Server::resume(ListenerPause reason) {
if (pauseMask_.clear(reason)) {
applyListenerEvents(EPOLLIN);
}
}
void Server::applyListenerEvents(std::uint32_t events) {
for (std::uint32_t listenerIndex = 0; listenerIndex < listeners_.size();
++listenerIndex) {
if (!listeners_[listenerIndex].valid()) {
continue;
}
try {
epoll_.modify(listeners_[listenerIndex].fd(), events,
listenerToken(listenerIndex));
} catch (const Epoll::Error& error) {
log::error("{}", error.what());
stopListener(listenerIndex);
}
}
}
void Server::expireBackoff() {
if (backoffExpiry_.has_value() &&
std::chrono::steady_clock::now() >= *backoffExpiry_) {
// Removes the stored value, leaving the optional empty (std::nullopt).
backoffExpiry_.reset();
resume(ListenerPause::AcceptBackoff);
}
}
int Server::millisUntilDeadline() const {
const auto now = std::chrono::steady_clock::now();
auto deadline = now + MAX_IDLE_WAIT;
// Dereferencing backoffExpiry_ accesses its stored time after has_value()
// confirms that one exists. Choose the earlier of that time and the periodic
// shutdown deadline, so a new loop iteration does not delay the retry.
if (backoffExpiry_.has_value() && *backoffExpiry_ < deadline) {
deadline = *backoffExpiry_;
}
// Example: backoffExpiry_ becomes the deadline at 500.80 ms. If now is
// 101.61 ms, 399.19 ms remain. Rounding down asks epoll_wait for 399 ms,
// so its timeout can end at 500.61 ms with 0.19 ms of backoff remaining.
//
// On the next loop, rounding 0.19 ms down produces a zero timeout, so
// epoll_wait returns immediately. At 0.01 ms per empty loop, this could
// waste about 19 iterations. Rounding 399.19 ms up to 400 ms makes the
// timeout end only after backoffExpiry_ has been reached.
const auto remaining =
std::chrono::ceil<std::chrono::milliseconds>(deadline - now);
// The deadline can pass while events are handled or this process is not
// scheduled. A negative timeout would make epoll_wait block indefinitely.
// Returning zero makes epoll_wait check for events without waiting. run()
// then calls expireBackoff(), which resumes the paused listeners.
return remaining.count() > 0 ? static_cast<int>(remaining.count()) : 0;
}
} // namespace webserv
+89
View File
@@ -0,0 +1,89 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Epoll.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/26 16:25:24 by acossari #+# #+# */
/* Updated: 2026/09/03 13:08:22 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "event/Epoll.hpp"
#include <cerrno>
#include <cstring>
#include <format>
#include <string_view>
#include <utility>
#include "Log.hpp"
namespace webserv {
namespace {
void control(int epollFd, int operation, int fd, std::uint32_t events,
std::uint64_t token) {
// epoll_event contains events and the data union. The outer braces initialize
// the struct, while the inner braces initialize data.u64 with the token.
epoll_event event{.events = events, .data = {.u64 = token}};
if (::epoll_ctl(epollFd, operation, fd, &event) == -1) {
const std::string_view operationName =
operation == EPOLL_CTL_ADD ? "EPOLL_CTL_ADD" : "EPOLL_CTL_MOD";
// The subject forbids errno only after read/recv/write/send.
throw Epoll::Error(std::format("epoll_ctl({}) on fd {}: {}", operationName,
fd, std::strerror(errno)));
}
}
} // namespace
Epoll::Error::Error(std::string message) : msg_(std::move(message)) {}
const char* Epoll::Error::what() const noexcept {
return msg_.c_str();
}
// The legacy size argument estimated how many file descriptors would be
// registered in this epoll instance. Current Linux ignores it but still
// requires a positive value, so one is the smallest valid argument.
Epoll::Epoll() : fd_(::epoll_create(1)) {
if (!fd_.valid()) {
throw Error(std::format("epoll_create: {}", std::strerror(errno)));
}
}
void Epoll::add(int fd, std::uint32_t events, std::uint64_t token) {
control(fd_.get(), EPOLL_CTL_ADD, fd, events, token);
}
void Epoll::modify(int fd, std::uint32_t events, std::uint64_t token) {
control(fd_.get(), EPOLL_CTL_MOD, fd, events, token);
}
void Epoll::remove(int fd) noexcept {
if (::epoll_ctl(fd_.get(), EPOLL_CTL_DEL, fd, nullptr) == -1) {
log::error("epoll_ctl(EPOLL_CTL_DEL) on fd {}: {}", fd,
std::strerror(errno));
}
}
std::span<const epoll_event> Epoll::wait(int timeoutMillis) {
const int readyCount =
::epoll_wait(fd_.get(), events_.data(),
static_cast<int>(EVENT_BATCH_CAPACITY), timeoutMillis);
if (readyCount == -1) {
if (errno == EINTR) {
// A signal caught by the program can interrupt epoll_wait.
// An empty span lets the caller stop or wait again. epoll_wait reports
// readiness only, so returning an empty span does not consume data.
return {};
}
throw Error(std::format("epoll_wait: {}", std::strerror(errno)));
}
return {events_.data(), static_cast<std::size_t>(readyCount)};
}
} // namespace webserv
+53
View File
@@ -0,0 +1,53 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* EventToken.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/25 18:00:56 by acossari #+# #+# */
/* Updated: 2026/08/25 19:45:51 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "event/EventToken.hpp"
#include <cassert>
namespace webserv {
// INDEX_MASK = binary 111...111 (30 low ones) = decimal 1,073,741,823.
// KIND_MASK = binary 11 (2 low ones) = decimal 3.
constexpr std::uint64_t INDEX_MASK =
(std::uint64_t{1} << EventToken::INDEX_BITS) - 1;
constexpr std::uint64_t KIND_MASK =
(std::uint64_t{1} << EventToken::KIND_BITS) - 1;
std::uint64_t pack(EventToken token) {
assert(token.index <= EventToken::MAX_INDEX);
assert(static_cast<std::uint64_t>(token.kind) <= KIND_MASK);
// Shift each field into place, then combine the disjoint bit ranges with OR:
// [ generation: 32 bits | kind: 2 bits | index: 30 bits ].
return static_cast<std::uint64_t>(token.index) |
(static_cast<std::uint64_t>(token.kind) << EventToken::INDEX_BITS) |
(static_cast<std::uint64_t>(token.generation)
<< (EventToken::INDEX_BITS + EventToken::KIND_BITS));
}
// Designated initialization constructs the returned EventToken by naming its
// fields in their declaration order.
//
// AND keeps only positions where a mask has 1. index already occupies the low
// 30 bits; kind is shifted right first so its 2 bits move there. generation is
// shifted right by 32 and needs no mask because no higher field remains.
EventToken unpack(std::uint64_t token) {
return EventToken{
.index = static_cast<std::uint32_t>(token & INDEX_MASK),
.kind = static_cast<EndpointKind>((token >> EventToken::INDEX_BITS) &
KIND_MASK),
.generation = static_cast<std::uint32_t>(
token >> (EventToken::INDEX_BITS + EventToken::KIND_BITS)),
};
}
} // namespace webserv
+77
View File
@@ -0,0 +1,77 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* main.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/19 12:36:23 by acossari #+# #+# */
/* Updated: 2026/08/20 22:18:42 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include <netinet/in.h>
#include <array>
#include <cerrno>
#include <csignal>
#include <cstdint>
#include <cstring>
#include <exception>
#include <iostream>
#include <string_view>
#include "Log.hpp"
#include "Server.hpp"
constexpr std::string_view DEFAULT_CONFIG_PATH = "conf/default.conf";
namespace {
// TEMP: Builds wildcard IPv4 addresses for the fixed echo server ports.
// Parsed configuration will eventually provide resolved listener addresses.
sockaddr_storage anyAddress(std::uint16_t port) {
const sockaddr_in address{.sin_family = AF_INET,
.sin_port = ::htons(port),
.sin_addr = {.s_addr = ::htonl(INADDR_ANY)},
.sin_zero = {}};
sockaddr_storage storage{};
std::memcpy(&storage, &address, sizeof(address));
return storage;
}
} // namespace
int main(int argc, char** argv) {
if (argc > 2) {
std::cerr << "usage: " << argv[0] << " [configuration file]\n";
return 1;
}
const std::string_view configPath =
(argc == 2) ? argv[1] : DEFAULT_CONFIG_PATH;
webserv::log::info("configuration file {}", configPath);
// Ignore SIGPIPE so writing to a closed socket or pipe reports an error
// instead of terminating the server. Other clients can then continue.
if (::signal(SIGPIPE, SIG_IGN) == SIG_ERR) {
std::cerr << "signal(SIGPIPE): " << std::strerror(errno) << '\n';
return 1;
}
try {
// TEMP: The echo server listens on three fixed ports until main obtains
// the listener addresses from the configuration file.
const std::array<sockaddr_storage, 3> endpoints{
anyAddress(8000), anyAddress(8001), anyAddress(8002)};
webserv::Server server(endpoints);
server.run();
} catch (const std::exception& error) {
webserv::log::error("{}", error.what());
return 1;
}
return 0;
}
+46
View File
@@ -0,0 +1,46 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* ByteBuffer.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/25 11:41:13 by acossari #+# #+# */
/* Updated: 2026/08/25 13:08:04 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "net/ByteBuffer.hpp"
#include <algorithm>
#include <cassert>
namespace webserv {
void ByteBuffer::commit(std::size_t count) {
assert(count <= CAPACITY - writePos_);
writePos_ += count;
}
void ByteBuffer::consume(std::size_t count) {
assert(count <= writePos_ - readPos_);
readPos_ += count;
if (readPos_ == writePos_) {
readPos_ = 0;
writePos_ = 0;
}
}
void ByteBuffer::compact() {
if (readPos_ == 0) {
return;
}
// Copies live bytes to the start of storage_. The caller decides when
// compaction is appropriate.
const std::size_t live = writePos_ - readPos_;
std::copy_n(storage_.data() + readPos_, live, storage_.data());
readPos_ = 0;
writePos_ = live;
}
} // namespace webserv
+168
View File
@@ -0,0 +1,168 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Connection.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/28 13:52:38 by acossari #+# #+# */
/* Updated: 2026/08/28 21:42:49 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "net/Connection.hpp"
#include <sys/epoll.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <algorithm>
#include <span>
#include <utility>
namespace webserv {
namespace {
// Writable tail still has space -> keep using it
//
// Writable tail is exhausted, but more than 16 KiB are still live
// -> wait for more bytes to be consumed because moving them is not worth it
//
// Writable tail is exhausted and at most 16 KiB are still live
// -> move the live bytes to the start and recover the writable tail
//
// Before: [ 48 KiB consumed ][ 16 KiB live ]
// After: [ 16 KiB live ][ 48 KiB writable tail ]
void compactIfDrained(ByteBuffer& buffer) {
if (buffer.writableTail().empty() &&
buffer.readable().size() <= Connection::LOW_WATERMARK) {
buffer.compact();
}
}
} // namespace
Connection::Connection(FileDescriptor socket) : socket_(std::move(socket)) {}
// Client can still send data -> Alive
// Read side closed, buffered data remains -> Alive
// Read side closed, both buffers empty -> Done
// recv() or send() failed -> Fatal
Connection::Outcome Connection::onClientEvent(std::uint32_t events) {
// 1) Interpret the readiness reported by epoll.
// EPOLLIN says recv() may progress.
// EPOLLOUT says send() may progress.
// EPOLLRDHUP (epoll read hangup) says the peer closed its write side.
// It counts as read readiness because recv() still has to return any buffered
// bytes before it returns zero.
const bool readReady = (events & (EPOLLIN | EPOLLRDHUP)) != 0;
const bool writeReady = (events & EPOLLOUT) != 0;
// 2) Combine kernel readiness with the connection's current state.
const bool canRead =
readReady && !readClosed_ && !receiveBuffer_.writableTail().empty();
const bool canWrite = writeReady && !sendBuffer_.readable().empty();
// 3) Perform at most one socket operation. Read wins a tie, but this priority
// ends when receiveBuffer_ has no writable tail and canRead becomes false.
bool socketUsable = true;
if (canRead) {
socketUsable = receive();
} else if (canWrite) {
socketUsable = transmit();
}
if (!socketUsable) {
return {.lifecycle = Lifecycle::Fatal, .desiredEvents = 0};
}
// 4) TEMP echo path. Compact sendBuffer_ if needed, forward received bytes
// into it, then compact receiveBuffer_ after those bytes have been consumed.
compactIfDrained(sendBuffer_);
forwardReceivedBytes();
compactIfDrained(receiveBuffer_);
// 5) A closed read side ends the connection only after both buffers drain.
const bool drained =
sendBuffer_.readable().empty() && receiveBuffer_.readable().empty();
return {.lifecycle =
(readClosed_ && drained) ? Lifecycle::Done : Lifecycle::Alive,
.desiredEvents = desiredEvents()};
}
// Read open, receive space, nothing to send -> EPOLLIN | EPOLLRDHUP
// Read open, receive space, output pending -> EPOLLIN | EPOLLRDHUP | EPOLLOUT
// Read open, receive full, nothing to send -> EPOLLRDHUP
// Read open, receive full, output pending -> EPOLLRDHUP | EPOLLOUT
// Read closed, output pending -> EPOLLOUT
// Read closed, nothing to send -> 0
std::uint32_t Connection::desiredEvents() noexcept {
std::uint32_t events = 0;
if (!readClosed_) {
events |= EPOLLRDHUP;
// Backpressure: omit EPOLLIN while the receive buffer has no writable tail.
// TCP slows the peer until space returns.
if (!receiveBuffer_.writableTail().empty()) {
events |= EPOLLIN;
}
}
if (!sendBuffer_.readable().empty()) {
events |= EPOLLOUT;
}
return events;
}
// true keeps the socket usable, while false retires the connection.
// recv() > 0 (bytes received and committed to the buffer) -> true
// recv() == 0 (the client has finished sending) -> true
// recv() < 0 (socket error) -> false
bool Connection::receive() {
const std::span<char> tail = receiveBuffer_.writableTail();
// May write at most tail.size() bytes into the free buffer space.
// The final zero requests no special options.
const ssize_t count = ::recv(socket_.get(), tail.data(), tail.size(), 0);
if (count > 0) {
// Advances writePos_ so the bytes written become readable.
// Before: [ readable bytes ][ writable tail ]
// After: [ readable bytes ][ received bytes ][ writable tail ]
receiveBuffer_.commit(static_cast<std::size_t>(count));
return true;
}
// EOF is not a socket error. Pending response bytes may still be sent, so the
// socket remains usable.
if (count == 0) {
readClosed_ = true;
return true;
}
return false;
}
bool Connection::transmit() {
const std::span<const char> pending = sendBuffer_.readable();
const ssize_t count =
::send(socket_.get(), pending.data(), pending.size(), 0);
if (count <= 0) {
return false;
}
// Advances readPos_ so the bytes sent are no longer readable.
// Before: [ bytes pending to send ]
// After: [ bytes accepted ][ bytes still pending to send ]
sendBuffer_.consume(static_cast<std::size_t>(count));
return true;
}
// TEMP echo
void Connection::forwardReceivedBytes() {
const std::span<const char> pending = receiveBuffer_.readable();
const std::span<char> tail = sendBuffer_.writableTail();
const std::size_t count = std::min(pending.size(), tail.size());
if (count == 0) {
return;
}
std::copy_n(pending.begin(), count, tail.begin());
sendBuffer_.commit(count);
receiveBuffer_.consume(count);
}
} // namespace webserv
+96
View File
@@ -0,0 +1,96 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* FileDescriptor.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/24 16:23:52 by acossari #+# #+# */
/* Updated: 2026/08/28 16:13:23 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "net/FileDescriptor.hpp"
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <format>
#include <utility>
namespace webserv {
// "Sink parameter": consumes the local parameter by moving its value into msg_
// lvalue -> copy into message -> move into msg_ (1 copy, worst case scenario)
// rvalue -> move into message -> move into msg_ (0 copies)
FileDescriptor::Error::Error(std::string message) : msg_(std::move(message)) {}
const char* FileDescriptor::Error::what() const noexcept {
return msg_.c_str();
}
// A valid fd is either made close-on-exec or closed before construction fails.
FileDescriptor::FileDescriptor(int fd) : fd_(fd) {
if (fd_ < 0) {
return;
}
// fork() inherits this fd, but a successful execve() closes it before the
// new program starts, preventing CGI processes from retaining server fds.
if (::fcntl(fd_, F_SETFD, FD_CLOEXEC) == -1) {
// The subject forbids errno only after read/recv/write/send.
// Save it before close(), which may overwrite the original fcntl error.
const int errnoCode = errno;
::close(fd_);
fd_ = -1;
throw Error(std::format("fcntl(F_SETFD, FD_CLOEXEC) on fd {}: {}", fd,
std::strerror(errnoCode)));
}
}
FileDescriptor::~FileDescriptor() {
reset();
}
// The FileDescriptor&& parameter is an rvalue reference and a sink. It accepts
// only a temporary or an object passed with std::move(). The constructor takes
// ownership of that object's fd, so only the new FileDescriptor will close it.
FileDescriptor::FileDescriptor(FileDescriptor&& other) noexcept
: fd_(std::exchange(other.fd_, -1)) {}
FileDescriptor& FileDescriptor::operator=(FileDescriptor&& other) noexcept {
if (this != &other) {
reset();
fd_ = std::exchange(other.fd_, -1);
}
return *this;
}
void FileDescriptor::setNonBlocking() {
if (!valid()) {
throw Error("setNonBlocking() on an invalid file descriptor");
}
// Read, modify, write pattern: F_SETFL replaces the whole status flags word,
// so passing O_NONBLOCK alone would clear whatever else is set.
const int flags = ::fcntl(fd_, F_GETFL);
if (flags == -1) {
throw Error(
std::format("fcntl(F_GETFL) on fd {}: {}", fd_, std::strerror(errno)));
}
if (::fcntl(fd_, F_SETFL, flags | O_NONBLOCK) == -1) {
throw Error(std::format("fcntl(F_SETFL, O_NONBLOCK) on fd {}: {}", fd_,
std::strerror(errno)));
}
}
void FileDescriptor::reset() noexcept {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
} // namespace webserv
+87
View File
@@ -0,0 +1,87 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* Listener.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/27 15:51:53 by acossari #+# #+# */
/* Updated: 2026/08/27 18:12:50 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "net/Listener.hpp"
#include <netinet/in.h>
#include <cerrno>
#include <cstring>
#include <format>
#include <utility>
namespace webserv {
namespace {
socklen_t addressLength(const sockaddr_storage& address) {
// AF_INET identifies IPv4, and AF_INET6 identifies IPv6.
// sockaddr_in and sockaddr_in6 are the matching structures that hold the
// socket address, including the family, IP address, and port.
switch (address.ss_family) {
case AF_INET:
return static_cast<socklen_t>(sizeof(sockaddr_in));
case AF_INET6:
return static_cast<socklen_t>(sizeof(sockaddr_in6));
default:
throw Listener::Error(
std::format("unsupported address family {}", address.ss_family));
}
}
} // namespace
Listener::Error::Error(std::string message) : msg_(std::move(message)) {}
const char* Listener::Error::what() const noexcept {
return msg_.c_str();
}
Listener::Listener(const sockaddr_storage& address)
// Creates a socket in the IPv4 or IPv6 family stored in address.
// SOCK_STREAM requests a byte stream, and protocol 0 selects TCP as the
// default stream protocol for both supported families.
: fd_(::socket(address.ss_family, SOCK_STREAM, 0)) {
if (!fd_.valid()) {
throw Error(std::format("socket: {}", std::strerror(errno)));
}
// Allows the local address and port to be reused without waiting for old TCP
// connection states to expire in the kernel. SO_REUSEADDR belongs to
// SOL_SOCKET, the group of general socket options.
// The old C socket API uses an int for this on/off setting.
const int enabled = 1;
if (::setsockopt(fd_.get(), SOL_SOCKET, SO_REUSEADDR, &enabled,
sizeof(enabled)) == -1) {
throw Error(
std::format("setsockopt(SO_REUSEADDR): {}", std::strerror(errno)));
}
fd_.setNonBlocking();
const socklen_t length = addressLength(address);
// Assigns the local IP address and port in address to the socket.
// reinterpret_cast presents the same pointer as the generic sockaddr pointer.
// length limits the read to the bytes of sockaddr_in or sockaddr_in6.
if (::bind(fd_.get(), reinterpret_cast<const sockaddr*>(&address), length) ==
-1) {
throw Error(std::format("bind: {}", std::strerror(errno)));
}
// Marks the bound socket as a listener for incoming TCP connections.
// SOMAXCONN requests the max pending connection queue allowed by the system.
if (::listen(fd_.get(), SOMAXCONN) == -1) {
throw Error(std::format("listen: {}", std::strerror(errno)));
}
}
} // namespace webserv
+43
View File
@@ -0,0 +1,43 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* PauseMask.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/25 20:03:02 by acossari #+# #+# */
/* Updated: 2026/08/25 21:35:31 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "net/PauseMask.hpp"
#include <utility>
namespace webserv {
bool PauseMask::set(ListenerPause reason) {
const std::uint8_t before = bits_;
// std::to_underlying converts any enum value to its declared integer type.
// Here ListenerPause becomes uint8_t. The |= bitwise OR assignment sets its
// flag in bits_ without clearing the pause reasons already present.
bits_ |= std::to_underlying(reason);
return before == 0 && bits_ != 0;
}
bool PauseMask::clear(ListenerPause reason) {
const std::uint8_t before = bits_;
// Cast reason to unsigned before applying ~ (bitwise NOT operator), which
// flips every bit. C++ promotes uint8_t to signed int because int can
// represent all its values; casting directly to unsigned avoids that.
// Ex. Clearing it while both pause reasons are active:
// bits_ 00000011
// reason 00000010
// & ~reason 11111101
// result 00000001
bits_ &= static_cast<std::uint8_t>(~static_cast<unsigned>(reason));
return before != 0 && bits_ == 0;
}
} // namespace webserv
+123
View File
@@ -0,0 +1,123 @@
/* ************************************************************************** */
/* */
/* ::: :::::::: */
/* SlotPool.cpp :+: :+: :+: */
/* +:+ +:+ +:+ */
/* By: acossari <acossari@student.codam.nl> +#+ +:+ +#+ */
/* +#+#+#+#+#+ +#+ */
/* Created: 2026/08/28 22:07:22 by acossari #+# #+# */
/* Updated: 2026/08/29 21:10:35 by acossari ### ########.fr */
/* */
/* ************************************************************************** */
#include "net/SlotPool.hpp"
#include <cassert>
#include <utility>
namespace webserv {
SlotPool::SlotPool() {
// Reserves room for at most one retired index per slot.
pendingFree_.reserve(CAPACITY);
// Links each free slot to the next. The sentinel value 256 ends the list.
for (std::uint32_t index = 0; index < CAPACITY; ++index) {
slots_[index].nextFree = index + 1;
}
}
// Receives ownership of the socket and transfers it to the Connection built in
// the selected slot. With no free slot, the local FileDescriptor closes the
// socket when this function returns.
std::optional<std::uint32_t> SlotPool::acquire(FileDescriptor socket) {
if (full()) {
return std::nullopt;
}
const std::uint32_t index = freeHead_;
Slot& slot = slots_[index];
// emplace constructs the Connection directly inside the optional because it
// cannot be copied or moved. Moving socket makes that Connection the owner
// of the accepted file descriptor.
slot.connection.emplace(std::move(socket));
freeHead_ = slot.nextFree;
slot.state = SlotState::InUse;
return index;
}
// Rejects tokens for inactive slots, unregistered endpoints, or an earlier
// endpoint generation so queued stale events cannot reach a Connection.
bool SlotPool::isCurrent(const EventToken& token) const noexcept {
if (token.kind == EndpointKind::Listener || token.index >= CAPACITY) {
return false;
}
const Slot& slot = slots_[token.index];
if (slot.state != SlotState::InUse) {
return false;
}
// Converts token.kind from an enum value to the array index 0, 1, or 2.
const Endpoint& endpoint =
slot.endpoints[static_cast<std::size_t>(token.kind)];
return endpoint.registered && endpoint.generation == token.generation;
}
Connection& SlotPool::connection(std::uint32_t index) {
assert(index < CAPACITY);
Slot& slot = slots_[index];
assert(slot.connection.has_value());
// Dereferences the optional to return its stored Connection by reference.
return *slot.connection;
}
SlotPool::Endpoint& SlotPool::endpoint(std::uint32_t index, EndpointKind kind) {
assert(index < CAPACITY);
const auto endpointIndex = static_cast<std::size_t>(kind);
assert(endpointIndex < ENDPOINTS_PER_SLOT);
return slots_[index].endpoints[endpointIndex];
}
// Quarantines an active slot until the current event batch is complete.
// pendingFree_ reserves CAPACITY entries and holds at most one per slot, so
// push_back() cannot reallocate or throw inside this noexcept function.
// NOLINTNEXTLINE(bugprone-exception-escape)
void SlotPool::retire(std::uint32_t index) noexcept {
assert(index < CAPACITY);
Slot& slot = slots_[index];
assert(slot.state == SlotState::InUse);
assert(slot.connection.has_value());
// The arrow accesses the Connection stored inside the optional. Its socket
// is released now, but the object stays alive until drain() destroys it.
slot.connection->close();
// References make the loop modify the Endpoint objects stored in the slot.
for (Endpoint& endpoint : slot.endpoints) {
endpoint.registered = false;
endpoint.registeredEvents = 0;
++endpoint.generation;
}
// Keeps the slot unavailable until drain() returns it to the free list.
slot.state = SlotState::Retired;
pendingFree_.push_back(index);
}
// Recycles all quarantined slots and returns how many became available.
std::size_t SlotPool::drain() {
// Saves how many Retired slots this call will return to the free list.
const std::size_t recycled = pendingFree_.size();
for (const std::uint32_t index : pendingFree_) {
Slot& slot = slots_[index];
// The batch is complete, so destroy the Connection and empty the optional.
slot.connection.reset();
slot.state = SlotState::Free;
// Prepends the recycled slot to the free list.
slot.nextFree = freeHead_;
freeHead_ = index;
}
pendingFree_.clear();
return recycled;
}
} // namespace webserv
+105
View File
@@ -0,0 +1,105 @@
# tester42 — official 42 webserv testers
Binaries attached to the intra evaluation scale, plus the fixtures they require.
All of them are Go programs, not stripped, so their expectations can be read out of the
binaries directly — `strings`, `nm`, and `go tool objdump`, which still shows the original
source file and line numbers. Nothing below is guesswork, and the whole sequence has also
been executed once against a probe stub.
## Layout and provenance
```
official/ tester, cgi_tester ← downloaded from intra. THE ONLY TRUSTED ONES.
YoupiBanane/ the fixture tree the tester requires (contents dictated by the tester
itself — see its printed instructions below, so it is reconstructable)
```
Both official binaries are **Linux ELF x86-64** and cannot run on macOS. They run in the
project's Linux container, which is also the platform the school VMs use.
## What the tester requires (verbatim from the binary)
```
- Download the cgi_test executable on the host
- Create a directory YoupiBanane with:
- A file name youpi.bad_extension
- A file name youpi.bla
- A sub directory called nop
- A file name youpi.bad_extension in nop
- A file name other.pouic in nop
- A sub directory called Yeah
- A file name not_happy.bad_extension in Yeah
- / must answer to GET request ONLY
- /directory/ must answer to GET request and the root of it would be the repository
YoupiBanane and if no file are requested, it should search for youpi.bad_extension files
- /post_body must answer anything to POST request with a maxBody of 100
- Any file with .bla as extension must answer to POST request by calling the cgi_test executable
```
## Test cases found in the binary
The complete executed order has now been verified against a passing probe stub.
| Area | Evidence in binary |
|---|---|
| `Test GET` | `GET on /directory/nop`, `directory/Yeah/not_happy.bad_extension`, `directory/nop/other.pouic` |
| 404 paths | `directory/oulalala`, `directory/nop/other.pouac` |
| `Test HEAD` | `HEAD /` is exercised and must return `405`; general HEAD support is not tested |
| `Test POST` | `/post_body` with `maxBody 100` |
| CGI | `Post on /directory/youpi.bla with size 100000000` (100 MB body into CGI), `bad cgi returned body content` |
| Concurrency | `Test multiple workers(%d) doing multiple times(%d)`, `client disconnected` |
`FATAL ERROR ON LAST TEST:` is the message printed when a case fails.
## CGI environment the cgi_tester reads
```
REQUEST_METHOD SERVER_PROTOCOL CONTENT_LENGTH CONTENT_TYPE
QUERY_STRING PATH_INFO HTTP_HOST HTTP_<any request header>
```
The binary vendors Go's `net/http/cgi`, so the contract is that package's, not folklore.
Three of these are failure modes rather than niceties:
- **`SERVER_PROTOCOL` is mandatory.** `RequestFromMap` calls `http.ParseHTTPVersion` on it and
aborts with `cgi: invalid SERVER_PROTOCOL version` if it is missing. The CGI never runs.
- **`PATH_INFO` must equal the request path**, with `SCRIPT_NAME` left empty. The handler
compares `os.Getenv("PATH_INFO")` against `r.URL.Path` and answers `500 PATH_INFO incorrect`
otherwise. Splitting them the way RFC 3875 prescribes **fails this tester**.
- **Arbitrary headers must be exported as `HTTP_*`** (uppercase, dashes to underscores). One
test reads `X-SECRET-HEADER-FOR-TEST`, so `HTTP_X_SECRET_HEADER_FOR_TEST` has to be there.
- `CONTENT_LENGTH` must be exact — including for the 100 MB POST. Wrong value gives
`cgi: bad CONTENT_LENGTH in environment`; a missing one makes the CGI read zero bytes,
because `Request()` wraps stdin in `io.LimitReader(os.Stdin, r.ContentLength)`.
- `CONTENT_TYPE` arrives as `test/file`. Not a real MIME type — forward it verbatim.
Its output starts with a `Status: NNN Text` header, which the server must turn into the HTTP
status line rather than forward. No `Content-Length` is emitted: EOF ends the body.
## Scope implications
Passing this tester requires more than the subject text asks for:
- **location matched by extension** (`*.bla`), not only by path prefix, and taking precedence
over the prefix match so that `POST /directory/youpi.bla` reaches the CGI while
`GET` on the same path serves the file
- **`alias` semantics** distinct from `root`: `/directory/` maps onto `YoupiBanane`, which has
no `directory/` subtree, so plain `root` cannot express it
- **per-location body limit** (`/post_body`, 100 bytes), enforced on the **decoded** body —
every POST the tester sends is chunked, so there is no `Content-Length` to check
- a 100 MB request body streamed into a CGI without buffering it all in memory
Nested `location` blocks are *not* required by this binary; they are kept in the config
grammar because the grammar is expensive to change later, not because a test needs them.
This official binary does **not** execute PUT or DELETE. DELETE and uploads remain mandatory
because the subject and evaluation checklist require them; they need project-owned tests.
## Running
```bash
# inside the Linux container, from the directory that contains YoupiBanane
./official/tester http://localhost:8000
```
The config must point the `.bla` CGI at `official/cgi_tester`.
@@ -0,0 +1 @@
unhappyyyyy
+1
View File
@@ -0,0 +1 @@
this is YoupiBanane/directory/youpi.bla
+1
View File
@@ -0,0 +1 @@
This is the default index yo!
+1
View File
@@ -0,0 +1 @@
pouic content
@@ -0,0 +1 @@
bad extension?
+1
View File
@@ -0,0 +1 @@
Nope
+1
View File
@@ -0,0 +1 @@
yololo
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<meta charset="utf-8">
<title>404 Not Found</title>
<h1>404 Not Found</h1>
<p>Configured error page.</p>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<meta charset="utf-8">
<title>Server error</title>
<h1>Server error</h1>
<p>Configured error page.</p>
+1
View File
@@ -0,0 +1 @@
A plain file, so /files/ has something to list.
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<meta charset="utf-8">
<title>webserv</title>
<h1>webserv</h1>
<p>Static root served from <code>./www</code>.</p>
<ul>
<li><a href="/files/">/files/</a> — directory listing</li>
<li><a href="/old">/old</a> — 301 redirect</li>
</ul>
+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<meta charset="utf-8">
<title>webserv.local</title>
<h1>webserv.local</h1>
<p>Different server block, same interface and port. Picked by the Host header.</p>