commit b15ce130f9a7a1ef74730e2d75230d2d660c0ffa Author: Antonio Cossari <155569812+antoniocossari@users.noreply.github.com> Date: Mon Sep 7 00:45:35 2026 +0200 first commit: echo server diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..1ee2f2d --- /dev/null +++ b/.clang-format @@ -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 diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 0000000..4e3458f --- /dev/null +++ b/.clang-tidy @@ -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. +# 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: '' diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..a5831b9 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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 + } + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bcabf0 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f60a7a2 --- /dev/null +++ b/Makefile @@ -0,0 +1,105 @@ +# **************************************************************************** # +# # +# ::: :::::::: # +# Makefile :+: :+: :+: # +# +:+ +:+ +:+ # +# By: acossari +#+ +:+ +#+ # +# +#+#+#+#+#+ +#+ # +# 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 . +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 diff --git a/conf/default.conf b/conf/default.conf new file mode 100644 index 0000000..f1e2bbc --- /dev/null +++ b/conf/default.conf @@ -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; + } +} diff --git a/conf/tester.conf b/conf/tester.conf new file mode 100644 index 0000000..5582731 --- /dev/null +++ b/conf/tester.conf @@ -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; + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..92bf8ac --- /dev/null +++ b/docker/Dockerfile @@ -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 on 202002L, +# so clang + libstdc++ compiles "#include " 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. "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"] diff --git a/docker/compose.yml b/docker/compose.yml new file mode 100644 index 0000000..25de1bb --- /dev/null +++ b/docker/compose.yml @@ -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 diff --git a/include/Log.hpp b/include/Log.hpp new file mode 100644 index 0000000..e692f3c --- /dev/null +++ b/include/Log.hpp @@ -0,0 +1,61 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Log.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/19 20:30:58 by acossari #+# #+# */ +/* Updated: 2026/08/20 22:18:40 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include +#include + +// 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 +void debug(std::format_string 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 +void info(std::format_string formatString, Args&&... args) { + vlog(Level::Info, formatString.get(), std::make_format_args(args...)); +} + +template +void warn(std::format_string formatString, Args&&... args) { + vlog(Level::Warn, formatString.get(), std::make_format_args(args...)); +} + +template +void error(std::format_string formatString, Args&&... args) { + vlog(Level::Error, formatString.get(), std::make_format_args(args...)); +} + +} // namespace webserv::log diff --git a/include/Result.hpp b/include/Result.hpp new file mode 100644 index 0000000..e9add77 --- /dev/null +++ b/include/Result.hpp @@ -0,0 +1,27 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Result.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/20 20:02:19 by acossari #+# #+# */ +/* Updated: 2026/08/21 20:02:26 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include + +#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 +using Result = std::expected; + +} // namespace webserv diff --git a/include/Server.hpp b/include/Server.hpp new file mode 100644 index 0000000..4d05a54 --- /dev/null +++ b/include/Server.hpp @@ -0,0 +1,78 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Server.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/09/01 09:13:02 by acossari #+# #+# */ +/* Updated: 2026/09/01 19:00:54 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include + +#include +#include +#include +#include +#include + +#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 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 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 backoffExpiry_; +}; + +} // namespace webserv diff --git a/include/event/Epoll.hpp b/include/event/Epoll.hpp new file mode 100644 index 0000000..73792e8 --- /dev/null +++ b/include/event/Epoll.hpp @@ -0,0 +1,57 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Epoll.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/26 11:34:23 by acossari #+# #+# */ +/* Updated: 2026/08/26 11:34:25 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +#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 wait(int timeoutMillis); + + private: + FileDescriptor fd_; + std::array events_{}; +}; + +} // namespace webserv diff --git a/include/event/EventToken.hpp b/include/event/EventToken.hpp new file mode 100644 index 0000000..3ca1686 --- /dev/null +++ b/include/event/EventToken.hpp @@ -0,0 +1,56 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* EventToken.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/25 13:26:42 by acossari #+# #+# */ +/* Updated: 2026/08/25 19:06:13 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include + +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 diff --git a/include/http/HttpStatus.hpp b/include/http/HttpStatus.hpp new file mode 100644 index 0000000..c09d6ae --- /dev/null +++ b/include/http/HttpStatus.hpp @@ -0,0 +1,50 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* HttpStatus.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* 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 diff --git a/include/net/ByteBuffer.hpp b/include/net/ByteBuffer.hpp new file mode 100644 index 0000000..2ab7143 --- /dev/null +++ b/include/net/ByteBuffer.hpp @@ -0,0 +1,61 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* ByteBuffer.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/25 09:30:45 by acossari #+# #+# */ +/* Updated: 2026/08/25 13:26:00 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include +#include +#include + +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 readable() const { + return {storage_.data() + readPos_, writePos_ - readPos_}; + } + + std::span 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 storage_ = std::vector(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 diff --git a/include/net/Connection.hpp b/include/net/Connection.hpp new file mode 100644 index 0000000..47a487c --- /dev/null +++ b/include/net/Connection.hpp @@ -0,0 +1,71 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Connection.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/28 09:21:04 by acossari #+# #+# */ +/* Updated: 2026/08/29 18:07:36 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include +#include + +#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 diff --git a/include/net/FileDescriptor.hpp b/include/net/FileDescriptor.hpp new file mode 100644 index 0000000..e81033a --- /dev/null +++ b/include/net/FileDescriptor.hpp @@ -0,0 +1,61 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* FileDescriptor.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/24 16:24:03 by acossari #+# #+# */ +/* Updated: 2026/08/24 20:05:59 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include +#include + +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 diff --git a/include/net/Listener.hpp b/include/net/Listener.hpp new file mode 100644 index 0000000..afb4534 --- /dev/null +++ b/include/net/Listener.hpp @@ -0,0 +1,48 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Listener.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/27 15:52:02 by acossari #+# #+# */ +/* Updated: 2026/09/01 18:10:13 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include + +#include +#include + +#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 diff --git a/include/net/PauseMask.hpp b/include/net/PauseMask.hpp new file mode 100644 index 0000000..9b73221 --- /dev/null +++ b/include/net/PauseMask.hpp @@ -0,0 +1,37 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* PauseMask.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/25 20:00:34 by acossari #+# #+# */ +/* Updated: 2026/08/25 20:00:38 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include + +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 diff --git a/include/net/SlotPool.hpp b/include/net/SlotPool.hpp new file mode 100644 index 0000000..ce2bc2a --- /dev/null +++ b/include/net/SlotPool.hpp @@ -0,0 +1,104 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* SlotPool.hpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/28 21:51:32 by acossari #+# #+# */ +/* Updated: 2026/08/29 20:33:13 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#pragma once + +#include +#include +#include +#include +#include + +#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 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 endpoints; + std::optional connection; + SlotState state = SlotState::Free; + std::uint32_t nextFree = 0; + }; + + std::array 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 pendingFree_; + std::uint32_t freeHead_ = 0; +}; + +} // namespace webserv diff --git a/src/Log.cpp b/src/Log.cpp new file mode 100644 index 0000000..4377d96 --- /dev/null +++ b/src/Log.cpp @@ -0,0 +1,75 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Log.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/20 16:55:22 by acossari #+# #+# */ +/* Updated: 2026/08/21 17:04:33 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "Log.hpp" + +#include +#include +#include + +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::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 diff --git a/src/Server.cpp b/src/Server.cpp new file mode 100644 index 0000000..8f1aaa2 --- /dev/null +++ b/src/Server.cpp @@ -0,0 +1,389 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Server.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/09/01 19:00:50 by acossari #+# #+# */ +/* Updated: 2026/09/04 07:58:42 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "Server.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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 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(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(remaining.count()) : 0; +} + +} // namespace webserv diff --git a/src/event/Epoll.cpp b/src/event/Epoll.cpp new file mode 100644 index 0000000..380e521 --- /dev/null +++ b/src/event/Epoll.cpp @@ -0,0 +1,89 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Epoll.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/26 16:25:24 by acossari #+# #+# */ +/* Updated: 2026/09/03 13:08:22 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "event/Epoll.hpp" + +#include +#include +#include +#include +#include + +#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 Epoll::wait(int timeoutMillis) { + const int readyCount = + ::epoll_wait(fd_.get(), events_.data(), + static_cast(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(readyCount)}; +} + +} // namespace webserv diff --git a/src/event/EventToken.cpp b/src/event/EventToken.cpp new file mode 100644 index 0000000..9308f56 --- /dev/null +++ b/src/event/EventToken.cpp @@ -0,0 +1,53 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* EventToken.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/25 18:00:56 by acossari #+# #+# */ +/* Updated: 2026/08/25 19:45:51 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "event/EventToken.hpp" + +#include + +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(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(token.index) | + (static_cast(token.kind) << EventToken::INDEX_BITS) | + (static_cast(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(token & INDEX_MASK), + .kind = static_cast((token >> EventToken::INDEX_BITS) & + KIND_MASK), + .generation = static_cast( + token >> (EventToken::INDEX_BITS + EventToken::KIND_BITS)), + }; +} + +} // namespace webserv diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..584b984 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,77 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* main.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/19 12:36:23 by acossari #+# #+# */ +/* Updated: 2026/08/20 22:18:42 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#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 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; +} diff --git a/src/net/ByteBuffer.cpp b/src/net/ByteBuffer.cpp new file mode 100644 index 0000000..c520076 --- /dev/null +++ b/src/net/ByteBuffer.cpp @@ -0,0 +1,46 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* ByteBuffer.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/25 11:41:13 by acossari #+# #+# */ +/* Updated: 2026/08/25 13:08:04 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "net/ByteBuffer.hpp" + +#include +#include + +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 diff --git a/src/net/Connection.cpp b/src/net/Connection.cpp new file mode 100644 index 0000000..a8b1912 --- /dev/null +++ b/src/net/Connection.cpp @@ -0,0 +1,168 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Connection.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/28 13:52:38 by acossari #+# #+# */ +/* Updated: 2026/08/28 21:42:49 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "net/Connection.hpp" + +#include +#include +#include + +#include +#include +#include + +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 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(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 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(count)); + return true; +} + +// TEMP echo +void Connection::forwardReceivedBytes() { + const std::span pending = receiveBuffer_.readable(); + const std::span 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 diff --git a/src/net/FileDescriptor.cpp b/src/net/FileDescriptor.cpp new file mode 100644 index 0000000..e742922 --- /dev/null +++ b/src/net/FileDescriptor.cpp @@ -0,0 +1,96 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* FileDescriptor.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/24 16:23:52 by acossari #+# #+# */ +/* Updated: 2026/08/28 16:13:23 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "net/FileDescriptor.hpp" + +#include +#include + +#include +#include +#include +#include + +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 diff --git a/src/net/Listener.cpp b/src/net/Listener.cpp new file mode 100644 index 0000000..50470e5 --- /dev/null +++ b/src/net/Listener.cpp @@ -0,0 +1,87 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* Listener.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/27 15:51:53 by acossari #+# #+# */ +/* Updated: 2026/08/27 18:12:50 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "net/Listener.hpp" + +#include + +#include +#include +#include +#include + +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(sizeof(sockaddr_in)); + case AF_INET6: + return static_cast(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(&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 diff --git a/src/net/PauseMask.cpp b/src/net/PauseMask.cpp new file mode 100644 index 0000000..95d0799 --- /dev/null +++ b/src/net/PauseMask.cpp @@ -0,0 +1,43 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* PauseMask.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/25 20:03:02 by acossari #+# #+# */ +/* Updated: 2026/08/25 21:35:31 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "net/PauseMask.hpp" + +#include + +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(~static_cast(reason)); + return before != 0 && bits_ == 0; +} + +} // namespace webserv diff --git a/src/net/SlotPool.cpp b/src/net/SlotPool.cpp new file mode 100644 index 0000000..5ecd060 --- /dev/null +++ b/src/net/SlotPool.cpp @@ -0,0 +1,123 @@ +/* ************************************************************************** */ +/* */ +/* ::: :::::::: */ +/* SlotPool.cpp :+: :+: :+: */ +/* +:+ +:+ +:+ */ +/* By: acossari +#+ +:+ +#+ */ +/* +#+#+#+#+#+ +#+ */ +/* Created: 2026/08/28 22:07:22 by acossari #+# #+# */ +/* Updated: 2026/08/29 21:10:35 by acossari ### ########.fr */ +/* */ +/* ************************************************************************** */ + +#include "net/SlotPool.hpp" + +#include +#include + +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 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(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(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 diff --git a/tester42/README.md b/tester42/README.md new file mode 100644 index 0000000..bb0b97a --- /dev/null +++ b/tester42/README.md @@ -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_ +``` + +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`. diff --git a/tester42/YoupiBanane/Yeah/not_happy.bad_extension b/tester42/YoupiBanane/Yeah/not_happy.bad_extension new file mode 100644 index 0000000..446b39c --- /dev/null +++ b/tester42/YoupiBanane/Yeah/not_happy.bad_extension @@ -0,0 +1 @@ +unhappyyyyy diff --git a/tester42/YoupiBanane/directory/youpi.bla b/tester42/YoupiBanane/directory/youpi.bla new file mode 100644 index 0000000..2008a84 --- /dev/null +++ b/tester42/YoupiBanane/directory/youpi.bla @@ -0,0 +1 @@ +this is YoupiBanane/directory/youpi.bla \ No newline at end of file diff --git a/tester42/YoupiBanane/index.html b/tester42/YoupiBanane/index.html new file mode 100644 index 0000000..6c6812c --- /dev/null +++ b/tester42/YoupiBanane/index.html @@ -0,0 +1 @@ +This is the default index yo! diff --git a/tester42/YoupiBanane/nop/other.pouic b/tester42/YoupiBanane/nop/other.pouic new file mode 100644 index 0000000..7c262f5 --- /dev/null +++ b/tester42/YoupiBanane/nop/other.pouic @@ -0,0 +1 @@ +pouic content diff --git a/tester42/YoupiBanane/nop/youpi.bad_extension b/tester42/YoupiBanane/nop/youpi.bad_extension new file mode 100644 index 0000000..37b9382 --- /dev/null +++ b/tester42/YoupiBanane/nop/youpi.bad_extension @@ -0,0 +1 @@ +bad extension? diff --git a/tester42/YoupiBanane/youpi.bad_extension b/tester42/YoupiBanane/youpi.bad_extension new file mode 100644 index 0000000..07a5c71 --- /dev/null +++ b/tester42/YoupiBanane/youpi.bad_extension @@ -0,0 +1 @@ +Nope \ No newline at end of file diff --git a/tester42/YoupiBanane/youpi.bla b/tester42/YoupiBanane/youpi.bla new file mode 100644 index 0000000..585a921 --- /dev/null +++ b/tester42/YoupiBanane/youpi.bla @@ -0,0 +1 @@ +yololo \ No newline at end of file diff --git a/tester42/official/cgi_tester b/tester42/official/cgi_tester new file mode 100755 index 0000000..9b8005f Binary files /dev/null and b/tester42/official/cgi_tester differ diff --git a/tester42/official/tester b/tester42/official/tester new file mode 100755 index 0000000..f15db02 Binary files /dev/null and b/tester42/official/tester differ diff --git a/www/errors/404.html b/www/errors/404.html new file mode 100644 index 0000000..03fba11 --- /dev/null +++ b/www/errors/404.html @@ -0,0 +1,5 @@ + + +404 Not Found +

404 Not Found

+

Configured error page.

diff --git a/www/errors/50x.html b/www/errors/50x.html new file mode 100644 index 0000000..07f714d --- /dev/null +++ b/www/errors/50x.html @@ -0,0 +1,5 @@ + + +Server error +

Server error

+

Configured error page.

diff --git a/www/files/hello.txt b/www/files/hello.txt new file mode 100644 index 0000000..4cbf2a1 --- /dev/null +++ b/www/files/hello.txt @@ -0,0 +1 @@ +A plain file, so /files/ has something to list. diff --git a/www/index.html b/www/index.html new file mode 100644 index 0000000..148bc99 --- /dev/null +++ b/www/index.html @@ -0,0 +1,9 @@ + + +webserv +

webserv

+

Static root served from ./www.

+
    +
  • /files/ — directory listing
  • +
  • /old — 301 redirect
  • +
diff --git a/www/vhost/index.html b/www/vhost/index.html new file mode 100644 index 0000000..10c4043 --- /dev/null +++ b/www/vhost/index.html @@ -0,0 +1,5 @@ + + +webserv.local +

webserv.local

+

Different server block, same interface and port. Picked by the Host header.