Skip to content

Commit 4dd7217

Browse files
etrclaude
andcommitted
TASK-045: Hook bus skeleton (hook_phase, hook_action, hook_handle, add_hook)
Lands the public types and per-phase storage for the lifecycle hook bus per §4.10 / DR-012 / PRD-HOOK-REQ-001..002. No phase fires yet; phases start firing in TASK-046..051. After this task the API surface compiles and a hook can be registered + removed. Public surface (four new MHD-clean headers): src/httpserver/hook_phase.hpp -- enum class hook_phase + count_ (11) src/httpserver/hook_action.hpp -- pass / respond_with / take_response && src/httpserver/hook_handle.hpp -- move-only RAII receipt src/httpserver/hook_context.hpp -- 11 per-phase ctx structs + peer_address + route_descriptor webserver::add_hook -- 11 overloads, one per phase, distinguished by the std::function signature; each validates the runtime phase tag, allocates a fresh slot_id (monotonic uint64), takes hook_table_mutex_ unique_lock, pushes into the matching per-phase vector, flips any_hooks_[phase] true. hook_handle::remove() / dtor re-takes the lock, linear-scans for slot_id, erases, and clears the gate if the vector is now empty. Storage lives on webserver_impl: shared_mutex hook_table_mutex_, atomic uint64 next_slot_id_, std::array<atomic<bool>, count_> any_hooks_, and 11 individually-typed std::vector<phase_entry<Sig>> members (signatures differ per phase). hook_handle is ABI-pinned <= 32 B via static_assert. Tests (3 new entries, total now 51): test/unit/header_hygiene_hooks_test.cpp -- per-header preprocessor sentinel that the four new hook headers don't transitively pull <microhttpd.h>/<gnutls/gnutls.h>/<sys/socket.h>/<sys/uio.h>. test/unit/hook_api_shape_test.cpp -- compile-time SFINAE gates (move-only, signature mismatch rejected) + runtime add/remove, double-remove no-op, RAII destruction, detach() disarm, any_hooks_ gate flip semantics. test/integ/hooks_no_firing.cpp -- registers one hook on every phase, drives one full HTTP round-trip, asserts all 11 counters stay zero. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6eb3c4c commit 4dd7217

14 files changed

Lines changed: 1490 additions & 3 deletions

src/Makefile.am

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ lib_LTLIBRARIES = libhttpserver.la
2525
# builds. The WS-off branch in websocket_handler.cpp provides stub
2626
# definitions (every member throws feature_unavailable except is_valid()
2727
# which returns false).
28-
libhttpserver_la_SOURCES = string_utilities.cpp webserver.cpp http_utils.cpp file_info.cpp http_request.cpp http_response.cpp create_webserver.cpp create_test_request.cpp websocket_handler.cpp detail/http_endpoint.cpp detail/body.cpp
28+
libhttpserver_la_SOURCES = string_utilities.cpp webserver.cpp http_utils.cpp file_info.cpp http_request.cpp http_response.cpp create_webserver.cpp create_test_request.cpp websocket_handler.cpp hook_handle.cpp detail/http_endpoint.cpp detail/body.cpp
2929
# noinst_HEADERS: shipped in the tarball but NEVER installed under $prefix/include.
3030
# Detail headers (httpserver/detail/*.hpp) live here so they cannot leak to
3131
# downstream consumers — the public surface comes in through <httpserver.hpp>.
3232
noinst_HEADERS = httpserver/string_utilities.hpp httpserver/detail/modded_request.hpp httpserver/detail/http_endpoint.hpp httpserver/detail/body.hpp httpserver/detail/webserver_impl.hpp httpserver/detail/http_request_impl.hpp httpserver/detail/route_entry.hpp httpserver/detail/lambda_resource.hpp httpserver/detail/radix_tree.hpp httpserver/detail/route_cache.hpp gettext.h
33-
nobase_include_HEADERS = httpserver.hpp httpserver/body_kind.hpp httpserver/constants.hpp httpserver/create_webserver.hpp httpserver/create_test_request.hpp httpserver/webserver.hpp httpserver/websocket_handler.hpp httpserver/http_utils.hpp httpserver/file_info.hpp httpserver/http_request.hpp httpserver/http_response.hpp httpserver/http_resource.hpp httpserver/feature_unavailable.hpp httpserver/iovec_entry.hpp httpserver/http_arg_value.hpp httpserver/http_method.hpp
33+
nobase_include_HEADERS = httpserver.hpp httpserver/body_kind.hpp httpserver/constants.hpp httpserver/create_webserver.hpp httpserver/create_test_request.hpp httpserver/webserver.hpp httpserver/websocket_handler.hpp httpserver/http_utils.hpp httpserver/file_info.hpp httpserver/http_request.hpp httpserver/http_response.hpp httpserver/http_resource.hpp httpserver/feature_unavailable.hpp httpserver/iovec_entry.hpp httpserver/http_arg_value.hpp httpserver/http_method.hpp httpserver/hook_phase.hpp httpserver/hook_action.hpp httpserver/hook_handle.hpp httpserver/hook_context.hpp
3434

3535
AM_CXXFLAGS += -fPIC -Wall
3636

src/hook_handle.cpp

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
/*
2+
This file is part of libhttpserver
3+
Copyright (C) 2011-2026 Sebastiano Merlino
4+
5+
This library is free software; you can redistribute it and/or
6+
modify it under the terms of the GNU Lesser General Public
7+
License as published by the Free Software Foundation; either
8+
version 2.1 of the License, or (at your option) any later version.
9+
10+
This library is distributed in the hope that it will be useful,
11+
but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
Lesser General Public License for more details.
14+
15+
You should have received a copy of the GNU Lesser General Public
16+
License along with this library; if not, write to the Free Software
17+
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301
18+
USA
19+
*/
20+
21+
// TASK-045 -- Out-of-line bodies for hook_handle and peer_address.
22+
//
23+
// hook_handle's destructor + remove() + move ops need the complete
24+
// type of detail::webserver_impl (the per-phase vectors live there),
25+
// so the bodies live here, not in the public header. Same pattern as
26+
// http_response's move/dtor.
27+
28+
#include "httpserver/hook_handle.hpp"
29+
30+
#include <cstdio>
31+
#include <mutex>
32+
#include <shared_mutex>
33+
#include <string>
34+
#include <utility>
35+
36+
#include "httpserver/hook_context.hpp"
37+
#include "httpserver/detail/webserver_impl.hpp"
38+
39+
namespace httpserver {
40+
41+
// ---- hook_handle ---------------------------------------------------------
42+
43+
hook_handle::hook_handle(hook_handle&& other) noexcept
44+
: impl_(other.impl_),
45+
slot_id_(other.slot_id_),
46+
phase_(other.phase_),
47+
armed_(other.armed_) {
48+
// Disarm the source so its destructor is a no-op.
49+
other.armed_ = false;
50+
other.impl_ = nullptr;
51+
}
52+
53+
hook_handle& hook_handle::operator=(hook_handle&& other) noexcept {
54+
if (this != &other) {
55+
// Remove any registration this object currently owns before
56+
// taking over the source's. remove() is idempotent and
57+
// noexcept, so this is safe even if `armed_` is already false.
58+
remove();
59+
impl_ = other.impl_;
60+
slot_id_ = other.slot_id_;
61+
phase_ = other.phase_;
62+
armed_ = other.armed_;
63+
other.armed_ = false;
64+
other.impl_ = nullptr;
65+
}
66+
return *this;
67+
}
68+
69+
hook_handle::~hook_handle() {
70+
if (armed_) {
71+
remove();
72+
}
73+
}
74+
75+
void hook_handle::remove() noexcept {
76+
if (!armed_ || impl_ == nullptr) {
77+
return;
78+
}
79+
// Snapshot the source state and disarm BEFORE doing the erase, so
80+
// any exception from the lambda's destructor (very unlikely, but
81+
// remove() is declared noexcept and std::terminate is the
82+
// observable outcome anyway) does not leave the handle in a
83+
// half-removed state.
84+
auto* impl = impl_;
85+
const auto phase = phase_;
86+
const auto id = slot_id_;
87+
armed_ = false;
88+
impl_ = nullptr;
89+
90+
std::unique_lock lock(impl->hook_table_mutex_);
91+
92+
// Linear scan for the matching slot. Phase vectors are tiny in
93+
// practice (single-digit hook counts). A not-found result is the
94+
// idempotent no-op case: the slot was already erased by an earlier
95+
// remove() or never inserted (defensive). Either way we still
96+
// re-evaluate the any_hooks_ gate against the current vector
97+
// emptiness.
98+
auto erase_if_found = [id](auto& vec) -> bool {
99+
for (auto it = vec.begin(); it != vec.end(); ++it) {
100+
if (it->slot_id == id) {
101+
vec.erase(it);
102+
return true;
103+
}
104+
}
105+
return false;
106+
};
107+
auto reset_gate_if_empty = [impl, phase](const auto& vec) {
108+
if (vec.empty()) {
109+
impl->any_hooks_[static_cast<std::size_t>(phase)].store(
110+
false, std::memory_order_release);
111+
}
112+
};
113+
114+
switch (phase) {
115+
case hook_phase::connection_opened:
116+
erase_if_found(impl->hooks_connection_opened_);
117+
reset_gate_if_empty(impl->hooks_connection_opened_);
118+
break;
119+
case hook_phase::accept_decision:
120+
erase_if_found(impl->hooks_accept_decision_);
121+
reset_gate_if_empty(impl->hooks_accept_decision_);
122+
break;
123+
case hook_phase::request_received:
124+
erase_if_found(impl->hooks_request_received_);
125+
reset_gate_if_empty(impl->hooks_request_received_);
126+
break;
127+
case hook_phase::body_chunk:
128+
erase_if_found(impl->hooks_body_chunk_);
129+
reset_gate_if_empty(impl->hooks_body_chunk_);
130+
break;
131+
case hook_phase::route_resolved:
132+
erase_if_found(impl->hooks_route_resolved_);
133+
reset_gate_if_empty(impl->hooks_route_resolved_);
134+
break;
135+
case hook_phase::before_handler:
136+
erase_if_found(impl->hooks_before_handler_);
137+
reset_gate_if_empty(impl->hooks_before_handler_);
138+
break;
139+
case hook_phase::handler_exception:
140+
erase_if_found(impl->hooks_handler_exception_);
141+
reset_gate_if_empty(impl->hooks_handler_exception_);
142+
break;
143+
case hook_phase::after_handler:
144+
erase_if_found(impl->hooks_after_handler_);
145+
reset_gate_if_empty(impl->hooks_after_handler_);
146+
break;
147+
case hook_phase::response_sent:
148+
erase_if_found(impl->hooks_response_sent_);
149+
reset_gate_if_empty(impl->hooks_response_sent_);
150+
break;
151+
case hook_phase::request_completed:
152+
erase_if_found(impl->hooks_request_completed_);
153+
reset_gate_if_empty(impl->hooks_request_completed_);
154+
break;
155+
case hook_phase::connection_closed:
156+
erase_if_found(impl->hooks_connection_closed_);
157+
reset_gate_if_empty(impl->hooks_connection_closed_);
158+
break;
159+
case hook_phase::count_:
160+
// Unreachable: an armed handle always carries a valid phase.
161+
break;
162+
}
163+
}
164+
165+
hook_handle hook_handle::detach() && noexcept {
166+
// Construct a disarmed handle from the same data; do NOT call the
167+
// private armed-ctor because the spec says detach() leaves the
168+
// underlying registration in place (no impl interaction).
169+
hook_handle out;
170+
out.impl_ = impl_;
171+
out.slot_id_ = slot_id_;
172+
out.phase_ = phase_;
173+
out.armed_ = false; // detach() = "destructor will not touch impl"
174+
// Disarm the source so its destructor is also a no-op.
175+
armed_ = false;
176+
impl_ = nullptr;
177+
return out;
178+
}
179+
180+
// ---- peer_address::to_string ---------------------------------------------
181+
182+
std::string peer_address::to_string() const {
183+
// No <netinet/in.h> / inet_ntop here so we keep this TU free of
184+
// backend-platform headers. The format is canonical-enough for
185+
// log lines without dragging in the full POSIX socket surface.
186+
// 46 is POSIX INET6_ADDRSTRLEN; we round up for snprintf's NUL.
187+
char buf[64];
188+
switch (fam) {
189+
case family::ipv4:
190+
std::snprintf(buf, sizeof(buf), "%u.%u.%u.%u",
191+
static_cast<unsigned>(bytes[0]),
192+
static_cast<unsigned>(bytes[1]),
193+
static_cast<unsigned>(bytes[2]),
194+
static_cast<unsigned>(bytes[3]));
195+
return std::string{buf};
196+
case family::ipv6: {
197+
// Group as eight uint16_t big-endian words, colon-separated.
198+
// Skip zero-compression for simplicity at TASK-045; TASK-046
199+
// can refine when telemetry/log requirements firm up.
200+
std::snprintf(buf, sizeof(buf),
201+
"%x:%x:%x:%x:%x:%x:%x:%x",
202+
(bytes[0] << 8) | bytes[1],
203+
(bytes[2] << 8) | bytes[3],
204+
(bytes[4] << 8) | bytes[5],
205+
(bytes[6] << 8) | bytes[7],
206+
(bytes[8] << 8) | bytes[9],
207+
(bytes[10] << 8) | bytes[11],
208+
(bytes[12] << 8) | bytes[13],
209+
(bytes[14] << 8) | bytes[15]);
210+
return std::string{buf};
211+
}
212+
case family::unspec:
213+
default:
214+
return std::string{};
215+
}
216+
}
217+
218+
} // namespace httpserver

src/httpserver.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
#include "httpserver/body_kind.hpp"
3131
#include "httpserver/constants.hpp"
3232
#include "httpserver/feature_unavailable.hpp"
33+
#include "httpserver/hook_action.hpp"
34+
#include "httpserver/hook_context.hpp"
35+
#include "httpserver/hook_handle.hpp"
36+
#include "httpserver/hook_phase.hpp"
3337
#include "httpserver/http_arg_value.hpp"
3438
#include "httpserver/http_method.hpp"
3539
#include "httpserver/http_request.hpp"

src/httpserver/detail/webserver_impl.hpp

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@
6565
#include <gnutls/gnutls.h>
6666
#endif // HAVE_GNUTLS
6767

68+
#include "httpserver/file_info.hpp"
6869
#include "httpserver/http_utils.hpp"
70+
#include "httpserver/hook_action.hpp"
71+
#include "httpserver/hook_context.hpp"
72+
#include "httpserver/hook_phase.hpp"
6973
#include "httpserver/detail/http_endpoint.hpp"
7074
#include "httpserver/detail/radix_tree.hpp"
7175
#include "httpserver/detail/route_cache.hpp"
@@ -282,6 +286,65 @@ class webserver_impl {
282286
// miss (tier_hit::none) so callers can branch deterministically.
283287
lookup_result lookup_v2(http_method method, const std::string& path);
284288

289+
// TASK-045 -- Lifecycle hook bus (skeleton, no firing yet).
290+
//
291+
// Per-phase callable storage. Each phase has its own
292+
// std::vector<phase_entry<Sig>> because phase signatures differ
293+
// (some return void, some return hook_action; ctx types differ).
294+
// Concurrency:
295+
// - `hook_table_mutex_` is a shared_mutex covering ALL eleven
296+
// phase vectors. Writers (add_hook, hook_handle::remove) take
297+
// a unique_lock; future firing sites (TASK-046+) will take
298+
// a shared_lock to snapshot a phase vector before iterating.
299+
// - `any_hooks_[i]` is a short-circuit gate read with relaxed/
300+
// acquire semantics on the dispatch hot path so a phase with
301+
// no registrations costs one atomic load. Set on first
302+
// registration of a phase and cleared when the phase's vector
303+
// drops to empty (memory_order_release on both edges).
304+
// - `next_slot_id_` is a monotonic 64-bit counter. It is never
305+
// reused, so a hook_handle whose slot has already been erased
306+
// simply finds no match in remove() -- the idempotent no-op
307+
// path. 64 bits is unboundedly large in practice (centuries
308+
// at any realistic registration rate).
309+
template <class Sig>
310+
struct phase_entry {
311+
std::uint64_t slot_id;
312+
std::function<Sig> fn;
313+
};
314+
315+
std::shared_mutex hook_table_mutex_;
316+
std::atomic<std::uint64_t> next_slot_id_{1};
317+
std::array<std::atomic<bool>,
318+
static_cast<std::size_t>(hook_phase::count_)> any_hooks_{};
319+
320+
std::vector<phase_entry<void(const ::httpserver::connection_open_ctx&)>>
321+
hooks_connection_opened_;
322+
std::vector<phase_entry<void(const ::httpserver::accept_ctx&)>>
323+
hooks_accept_decision_;
324+
std::vector<phase_entry<::httpserver::hook_action(
325+
::httpserver::request_received_ctx&)>>
326+
hooks_request_received_;
327+
std::vector<phase_entry<::httpserver::hook_action(
328+
::httpserver::body_chunk_ctx&)>>
329+
hooks_body_chunk_;
330+
std::vector<phase_entry<void(const ::httpserver::route_resolved_ctx&)>>
331+
hooks_route_resolved_;
332+
std::vector<phase_entry<::httpserver::hook_action(
333+
::httpserver::before_handler_ctx&)>>
334+
hooks_before_handler_;
335+
std::vector<phase_entry<::httpserver::hook_action(
336+
const ::httpserver::handler_exception_ctx&)>>
337+
hooks_handler_exception_;
338+
std::vector<phase_entry<::httpserver::hook_action(
339+
::httpserver::after_handler_ctx&)>>
340+
hooks_after_handler_;
341+
std::vector<phase_entry<void(const ::httpserver::response_sent_ctx&)>>
342+
hooks_response_sent_;
343+
std::vector<phase_entry<void(const ::httpserver::request_completed_ctx&)>>
344+
hooks_request_completed_;
345+
std::vector<phase_entry<void(const ::httpserver::connection_close_ctx&)>>
346+
hooks_connection_closed_;
347+
285348
std::shared_mutex bans_mutex;
286349
std::set<http::ip_representation> bans;
287350

0 commit comments

Comments
 (0)