Responsibility: Multi-subscriber extension surface spanning the connection → request → routing → handler → response → cleanup lifecycle. Replaces v1's patchwork of single-slot callbacks (which now exist as documented aliases per DR-012).
Phases. Eleven, in firing order:
| Phase | Fires at (file:symbol) | Short-circuit | Per-route eligible |
|---|---|---|---|
connection_opened |
detail/webserver_callbacks_lifecycle.cpp:connection_notify — MHD_CONNECTION_NOTIFY_STARTED |
no | no |
accept_decision |
detail/webserver_callbacks_lifecycle.cpp:policy_callback — after YES/NO decision |
no | no |
request_received |
webserver_body_pipeline.cpp:requests_answer_first_step — post header-parse, pre body-read |
yes | no (route not yet known) |
body_chunk |
webserver_body_pipeline.cpp:requests_answer_second_step — per chunk |
yes | no |
route_resolved |
webserver_request.cpp:fire_route_resolved_gated — called from webserver_request.cpp:finalize_answer, after lookup |
no | n/a (boundary phase) |
before_handler |
webserver_hook_firing.cpp:fire_before_handler_gated — called from webserver_request.cpp:finalize_answer, before dispatch |
yes | yes |
handler_exception |
webserver_dispatch.cpp:dispatch_resource_handler — each catch arm |
yes (maps exception to response) | yes |
after_handler |
webserver_hook_firing.cpp:fire_after_handler_gated — called from webserver_request.cpp:finalize_answer |
yes (replaces response) | yes |
response_sent |
webserver_hook_firing.cpp:fire_response_sent_gated — called from webserver_request.cpp:materialize_and_queue_response, post MHD_queue_response |
no | yes |
request_completed |
webserver_hook_firing.cpp:fire_request_completed_gated — called from webserver_callbacks.cpp:request_completed, NOTIFY_COMPLETED |
no | yes |
connection_closed |
detail/webserver_callbacks_lifecycle.cpp:connection_notify — NOTIFY_CLOSED |
no | no |
Implementation. Each phase has its own std::vector<std::function<...>> in webserver_impl, guarded by a single std::shared_mutex hook_table_mutex_. A per-phase std::atomic<bool> any_hooks_[hook_phase::count_] flag short-circuits the dispatch site to a relaxed atomic load and a compare-with-zero when no subscribers exist — the only hook-related cost on the hot request path for a server with zero hooks registered.
Per-route hooks live on http_resource in the same shape (per-phase vectors + any_hooks_ flag + the resource's own std::shared_mutex). The dispatch path takes a shared_lock on the resource's hook mutex after the existing route-table shared_lock. Lock order: route_table_mutex_ → resource hook mutex → hook_table_mutex_ (server-wide). The lookup pipeline never holds two locks across an iteration step: it copies the vector under a shared_lock, releases, and iterates the copy.
API surface (umbrella <httpserver.hpp>):
namespace httpserver {
enum class hook_phase {
connection_opened, accept_decision,
request_received, body_chunk,
route_resolved, before_handler, handler_exception,
after_handler, response_sent,
request_completed, connection_closed,
count_ // end sentinel
};
class hook_action {
public:
static hook_action pass();
static hook_action respond_with(http_response r);
bool is_pass() const noexcept;
http_response&& take_response() &&; // valid iff !is_pass()
};
class hook_handle {
public:
hook_handle() = default;
hook_handle(hook_handle&&) noexcept;
hook_handle& operator=(hook_handle&&) noexcept;
~hook_handle(); // removes unless detached
void remove() noexcept;
hook_handle detach() &&; // disarm the destructor
};
// Per-phase context structs — peer_address, request_received_ctx,
// body_chunk_ctx, route_resolved_ctx, before_handler_ctx,
// handler_exception_ctx, after_handler_ctx, response_sent_ctx,
// request_completed_ctx, connection_open_ctx, connection_close_ctx.
// All libhttpserver-defined; never expose MHD types (PRD-HDR-REQ-001).
// Observation-only contexts carry `const http_request*` / `const http_response*`;
// mutating contexts (`after_handler_ctx`) expose `http_response*` (mutable).
// On webserver — one add_hook overload per phase. Phase tag in the
// hook_phase enum selects the overload; the callable's signature is
// type-checked against the matching context struct at the call site.
hook_handle webserver::add_hook(hook_phase, std::function<...>);
// On http_resource — same shape, scoped to dispatches of this resource.
// Only the route-bound phases are accepted; the others throw
// std::invalid_argument.
hook_handle http_resource::add_hook(hook_phase, std::function<...>);
} // namespace httpserverConcurrency. add_hook and hook_handle::remove are safe to call from inside a hook (writer lock taken briefly). In-flight hook chains see a stable snapshot — the dispatch site copies the vector under a shared_lock, releases the lock, then iterates the copy. New registrations are picked up by the next firing of that phase.
Execution order within a phase. Server-wide hooks first (registration order), then per-route hooks (registration order). A short-circuit at any position skips the rest within the phase.
Exception policy. A throwing hook lands in the DR-009 §5.2 path — no new error contract. Practically: a hook throwing std::exception is caught, logged via log_error, and routed to the handler_exception chain (which itself is hookable — a handler_exception hook throwing recurses one level to the internal_error_handler alias and then to the hardcoded empty-body 500).
Zero-cost-when-unused. Per-phase std::atomic<bool> any_hooks_ flag. Verified by bench_hook_overhead (acceptance criterion of TASK-052) — a server with zero hooks should be within microbench noise of the pre-hook-system baseline.
after_handler firing rules. after_handler fires on the normal handler-return path and on the synthesised-404 path (no matching route), but NOT on the before_handler short-circuit path or on request_received/body_chunk short-circuit paths (no handler ran). By contrast, response_sent fires unconditionally after MHD_queue_response regardless of how the response was produced. Hook authors who need a uniform post-response observation point should use response_sent; authors who need to mutate the handler's response should use after_handler.
v1 aliases. The following v1-derived setters remain on create_webserver for ergonomic call sites; the Doxygen comment on each setter, the corresponding README section, and RELEASE_NOTES.md identify them as sugar that internally registers a hook at the indicated phase:
| Setter | Equivalent hook |
|---|---|
log_access(fn) |
response_sent |
not_found_handler(fn) |
route_resolved (observation-only stub — per DR-012 §4.10 this phase cannot mutate the response; the 404 body is synthesised separately at the v1 call site webserver_impl::not_found_page. The alias seat is a permanent architectural anchor (stable hook[0] index, observable hook count), not a deferred feature. Runs after any user route_resolved hooks that did not short-circuit) |
method_not_allowed_handler(fn) |
before_handler (when is_allowed(method) is false; runs after any user before_handler hook that did not short-circuit) |
internal_error_handler(fn) |
handler_exception (last-position fallback in the chain) |
auth_handler(fn) |
before_handler (runs before the method-allowed check) |
log_error(fn) |
not a hook alias — it is an MHD-level callback for backend errors, distinct from the request lifecycle |
file_cleanup_callback(fn) |
not a hook alias — file-upload cleanup is a separate post-upload concern, not a lifecycle phase |
Alias mutability. All v1 alias setters are construction-time-only. Their backing storage is wired during create_webserver → webserver construction and not mutated afterward. Two aliases (log_access, internal_error_handler) occupy dedicated single-slot members on webserver_impl (log_access_alias_, handler_exception_alias_); the other three (auth_handler, method_not_allowed_handler, not_found_handler) are seated into the regular per-phase hook vectors via add_hook(...).detach() at construction. In neither case is the slot mutable after webserver::start(). Users who need runtime registration or replacement of an extension point should use the hook bus directly: webserver::add_hook(phase, callable) returns a hook_handle that supports remove(). This is a deliberate v2.0 design choice (DR-012 / TASK-066): the hook bus IS the runtime extension surface; aliases are documented construction-time sugar. The semantics are pinned by log_access_alias_is_immutable_after_construction and handler_exception_alias_is_immutable_after_construction in test/unit/hooks_log_access_alias_slot_test.cpp.
auth_handler is fail-closed on exception. The generic short-circuit firing path (fire_short_circuit_hooks_for_phase) catches an exception thrown by a before_handler/request_received/body_chunk hook, logs it, and treats it as pass() — i.e. the chain continues. The auth_handler alias is the one deliberate exception to that policy: because the auth seat is a security boundary, letting a throwing auth callable fall through to the resource would be a fail-open on authentication (CWE-703). The alias therefore wraps its own callable invocation in a local fail-closed guard: an exception is caught, logged via log_dispatch_error, and short-circuits the request with a 500 instead of pass(). Because the short-circuit happens directly inside the before_handler guard, the handler_exception hook chain and the internal_error_handler alias are never invoked on this path — an operator who configures internal_error_handler expecting it to intercept every 500 will not see the auth fail-closed response there. This matches the §5.2 / DR-009 contract that "an exception thrown by a hook is routed through the same path as a throwing resource handler" (which terminates in a 500). Pinned by throwing_auth_handler_fails_closed_with_500 in test/unit/auth_handler_optional_signature_test.cpp (TASK-085). A user auth_handler that wants a custom non-500 rejection on its own error path must catch internally and return an engaged std::optional<http_response>.
Related requirements: PRD-HOOK-REQ-001..009. Related decisions: DR-012, §5.6.