Skip to content

Commit 6eb3c4c

Browse files
etrclaude
andcommitted
specs: lifecycle hook bus (DR-012 + TASK-045..052)
Planning-only commit. No code yet; subsequent task-branch PRs implement TASK-045..052 in order against feature/v2.0. Adds a multi-subscriber lifecycle hook system to v2.0, replacing v1's patchwork of single-slot callbacks (log_access, not_found_handler, method_not_allowed_handler, internal_error_handler, auth_handler) with one uniform webserver::add_hook(phase, callable) surface plus a per-route http_resource::add_hook(...) variant. Existing v1 setters survive as documented aliases (PRD-HOOK-REQ-009). Eleven phases spanning the connection -> request -> routing -> handler -> response -> cleanup lifecycle: connection_opened, accept_decision, request_received, body_chunk, route_resolved, before_handler, handler_exception, after_handler, response_sent, request_completed, connection_closed. Short-circuit allowed at four pre-handler phases (request_received, body_chunk, before_handler, handler_exception) and at the after_handler post-handler phase. Throwing hooks route through DR-9 §5.2. Closes (once TASK-046, 047, 050 land): #332 banned-IP log entry (accept_decision hook) #281 response-aware access log (response_sent context) #69 Common Log Format w/ time-taken (response_sent context) #273 early 413 on oversize body (request_received short-circuit) Partially addresses #272 (body_chunk observation; the buffer-steal half remains a v2.1 candidate needing a streaming-body API). Files added: specs/architecture/11-decisions/DR-012.md specs/architecture/04-components/hooks.md (§4.10) specs/tasks/M5-routing-lifecycle/TASK-045.md .. TASK-052.md Files updated: specs/product_specs.md - new §3.8 with PRD-HOOK-REQ-001..009 - §4 traceability line for API-HOOK specs/architecture/05-cross-cutting.md - new §5.6 hook lifecycle contract - four new public headers added to §5.5 header tree specs/tasks/_index.md - M5 milestone row updated - 8 task-status rows (045..052) - dependency-graph branch - PRD-HOOK coverage rows - DR-012 coverage row Per-route hooks (TASK-051) are restricted to phases that fire after route resolution. v1 alias retention is covered in TASK-048 (404/405/auth), TASK-049 (internal_error_handler), TASK-050 (log_access), and re-documented in TASK-052. TASK-052 explicitly touches back into the already-Done TASK-040 (examples), TASK-041 (README), TASK-042 (RELEASE_NOTES), TASK-043 (Doxygen) — the planned M6 touch-back called out when this scope was approved for inclusion in PR #374. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9c3d09c commit 6eb3c4c

13 files changed

Lines changed: 530 additions & 1 deletion

File tree

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
### 4.10 Hook bus
2+
3+
**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).
4+
5+
**Phases.** Eleven, in firing order:
6+
7+
| Phase | Fires at (file:symbol) | Short-circuit | Per-route eligible |
8+
|---|---|---|---|
9+
| `connection_opened` | `webserver.cpp:connection_notify``MHD_CONNECTION_NOTIFY_STARTED` | no | no |
10+
| `accept_decision` | `webserver.cpp:policy_callback` — after YES/NO decision | no | no |
11+
| `request_received` | `webserver.cpp:requests_answer_first_step` — post header-parse, pre body-read | **yes** | no (route not yet known) |
12+
| `body_chunk` | `webserver.cpp:requests_answer_second_step` — per chunk | **yes** | no |
13+
| `route_resolved` | `webserver.cpp:resolve_resource_for_request` — after lookup | no | n/a (boundary phase) |
14+
| `before_handler` | `webserver.cpp:dispatch_resource_handler` — start | **yes** | yes |
15+
| `handler_exception` | `webserver.cpp:dispatch_resource_handler` — each catch arm | **yes** (maps exception to response) | yes |
16+
| `after_handler` | between handler return and `materialize_and_queue_response` | **yes** (replaces response) | yes |
17+
| `response_sent` | `webserver.cpp:materialize_and_queue_response` — post `MHD_queue_response` | no | yes |
18+
| `request_completed` | `webserver.cpp:request_completed` — NOTIFY_COMPLETED | no | yes |
19+
| `connection_closed` | `webserver.cpp:connection_notify` — NOTIFY_CLOSED | no | no |
20+
21+
**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.
22+
23+
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.
24+
25+
**API surface (umbrella `<httpserver.hpp>`):**
26+
27+
```cpp
28+
namespace httpserver {
29+
30+
enum class hook_phase {
31+
connection_opened, accept_decision,
32+
request_received, body_chunk,
33+
route_resolved, before_handler, handler_exception,
34+
after_handler, response_sent,
35+
request_completed, connection_closed,
36+
count_ // end sentinel
37+
};
38+
39+
class hook_action {
40+
public:
41+
static hook_action pass();
42+
static hook_action respond_with(http_response r);
43+
bool is_pass() const noexcept;
44+
http_response&& take_response() &&; // valid iff !is_pass()
45+
};
46+
47+
class hook_handle {
48+
public:
49+
hook_handle() = default;
50+
hook_handle(hook_handle&&) noexcept;
51+
hook_handle& operator=(hook_handle&&) noexcept;
52+
~hook_handle(); // removes unless detached
53+
void remove() noexcept;
54+
hook_handle detach() &&; // disarm the destructor
55+
};
56+
57+
// Per-phase context structs — peer_address, request_received_ctx,
58+
// body_chunk_ctx, route_resolved_ctx, before_handler_ctx,
59+
// handler_exception_ctx, after_handler_ctx, response_sent_ctx,
60+
// request_completed_ctx, connection_open_ctx, connection_close_ctx.
61+
// All libhttpserver-defined; never expose MHD types (PRD-HDR-REQ-001).
62+
// Observation-only contexts pass `const http_request&` / `const http_response&`;
63+
// mutating contexts (`after_handler_ctx`) expose `http_response&`.
64+
65+
// On webserver — one add_hook overload per phase. Phase tag in the
66+
// hook_phase enum selects the overload; the callable's signature is
67+
// type-checked against the matching context struct at the call site.
68+
hook_handle webserver::add_hook(hook_phase, std::function<...>);
69+
70+
// On http_resource — same shape, scoped to dispatches of this resource.
71+
// Only the route-bound phases are accepted; the others throw
72+
// std::invalid_argument.
73+
hook_handle http_resource::add_hook(hook_phase, std::function<...>);
74+
75+
} // namespace httpserver
76+
```
77+
78+
**Concurrency.** `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.
79+
80+
**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.
81+
82+
**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).
83+
84+
**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.
85+
86+
**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:
87+
88+
| Setter | Equivalent hook |
89+
|---|---|
90+
| `log_access(fn)` | `response_sent` |
91+
| `not_found_handler(fn)` | `route_resolved` (when route is `nullopt`; runs after any user `route_resolved` hooks that did not short-circuit) |
92+
| `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) |
93+
| `internal_error_handler(fn)` | `handler_exception` (last-position fallback in the chain) |
94+
| `auth_handler(fn)` | `before_handler` (runs before the method-allowed check) |
95+
| `log_error(fn)` | *not* a hook alias — it is an MHD-level callback for backend errors, distinct from the request lifecycle |
96+
| `file_cleanup_callback(fn)` | *not* a hook alias — file-upload cleanup is a separate post-upload concern, not a lifecycle phase |
97+
98+
**Related requirements:** PRD-HOOK-REQ-001..009.
99+
**Related decisions:** DR-012, §5.6.
100+
101+
---

specs/architecture/05-cross-cutting.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ src/
5555
│ ├── create_webserver.hpp
5656
│ ├── create_test_request.hpp
5757
│ ├── file_info.hpp
58+
│ ├── hook_phase.hpp # NEW — hook_phase enum (DR-12)
59+
│ ├── hook_action.hpp # NEW — hook_action token (DR-12)
60+
│ ├── hook_handle.hpp # NEW — hook_handle RAII (DR-12)
61+
│ ├── hook_context.hpp # NEW — per-phase context structs (DR-12)
5862
│ └── detail/ # NOT installed (existing convention)
5963
│ ├── webserver_impl.hpp # NEW
6064
│ ├── http_request_impl.hpp # NEW
@@ -66,4 +70,19 @@ src/
6670

6771
Public headers gate on `_HTTPSERVER_HPP_INSIDE_` or `HTTPSERVER_COMPILATION`. `detail/` headers gate on `HTTPSERVER_COMPILATION` only (consumers cannot reach in). `Makefile.am` continues to install `httpserver/*.hpp` and exclude `httpserver/detail/`.
6872

73+
### 5.6 Hook lifecycle
74+
75+
**Contract (committed in DR-12):**
76+
77+
1. **Eleven phases** (`connection_opened`, `accept_decision`, `request_received`, `body_chunk`, `route_resolved`, `before_handler`, `handler_exception`, `after_handler`, `response_sent`, `request_completed`, `connection_closed`). See §4.10 for the firing-site map.
78+
2. **Multi-subscriber per phase.** Execution order: server-wide hooks first (registration order), then per-route hooks on `http_resource` (registration order).
79+
3. **Short-circuit** is allowed at the four pre-handler phases (`request_received`, `body_chunk`, `before_handler`, `handler_exception`) and at the `after_handler` post-handler phase. A hook short-circuits by returning `hook_action::respond_with(response)`. At pre-handler phases this skips the resource handler; at `after_handler` it replaces the in-flight response. Remaining hooks at the phase are skipped in both cases. `response_sent`, `request_completed`, `connection_opened`, `connection_closed`, `accept_decision`, `route_resolved` are observation-only.
80+
4. **Exceptions** thrown by a hook are caught and routed through §5.2 / DR-9 — same path as a throwing resource handler. The one exception: the `handler_exception` chain itself continues to the next hook when one of its hooks throws, because the whole point of the chain is exception recovery.
81+
5. **Thread safety.** `webserver::add_hook`, `http_resource::add_hook`, and `hook_handle::remove` are safe to call from inside a hook (mirrors §5.1 for `register_resource`). The dispatch site copies the relevant phase vector under a `shared_lock`, releases the lock, then iterates the copy — so an in-flight chain sees a stable snapshot.
82+
6. **Zero-cost when unused.** Per-phase `std::atomic<bool> any_hooks_` short-circuits the hot path to a relaxed atomic load + compare-with-zero when no subscribers exist. Verified by `bench_hook_overhead`.
83+
84+
**Lock order (additive to §5.1):** `route_table_mutex_` → resource's `hook_table_mutex_` → server-wide `hook_table_mutex_`. No two are held across an iteration step; each is taken, the vector is copied, and the lock is released before invocation.
85+
86+
**v1-shorthand aliases.** `log_access`, `not_found_handler`, `method_not_allowed_handler`, `internal_error_handler`, `auth_handler` survive on `create_webserver` as documented sugar. Each setter's Doxygen, the README, and `RELEASE_NOTES.md` identify it as an alias that internally registers a hook at the corresponding phase (see §4.10 table). `log_error` and `file_cleanup_callback` are NOT hook aliases — they are MHD-level / post-upload concerns distinct from the request lifecycle.
87+
6988
---
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
### DR-012: Lifecycle hook bus
2+
3+
**Status:** Accepted
4+
**Date:** 2026-05-21
5+
**Context:** v1's extension points are a patchwork of single-slot callbacks on `create_webserver` (`log_access`, `log_error`, `not_found_handler`, `method_not_allowed_handler`, `internal_error_handler`, `auth_handler`, `file_cleanup_callback`). Each adds one method to the public surface, each is single-subscriber, each fits one moment in the request lifecycle — and the set has gaps that surface as long-standing open issues: #332 (no hook fires when `policy_callback` rejects an IP), #281 and #69 (`log_access` runs before the response exists, so the user cannot record status, body size, or duration), #273 (no pre-body hook can short-circuit before the body is read), #272 partial (no per-chunk hook). v2.0 wants one extension mechanism with broad lifecycle coverage, multi-subscriber, and a documented short-circuit contract.
6+
7+
**Options considered:**
8+
1. **Single uniform bus, multi-subscriber, registration-order chain.** `webserver::add_hook(phase, callable)` returns a `hook_handle`. Existing single-slot setters retained as documented aliases that internally register a hook.
9+
2. **Single uniform bus, multi-subscriber, priority parameter.** Same as 1 but `add_hook` takes an `int priority`.
10+
3. **Keep per-callback setters, just add more.** Bolt on `on_accept`, `on_response_sent`, etc., each its own single-slot.
11+
4. **Multiple buses (one bus type per phase, separate registration call per phase).** Heavy on public API symbols.
12+
13+
**Decision:** Option 1.
14+
15+
**Rationale:** Option 3 is exactly the patchwork we want out of. Option 4 doubles the visible surface for no expressive gain. Option 2 looks attractive but priority-based dispatch is the kind of feature you can add non-breakingly later (just default `int priority = 0`) and never cleanly remove. Registration order is the simplest contract that works — peer libraries (Express, Koa, Sinatra) use the same model and users understand it. Single-slot setters survive as documented sugar so existing v2 code keeps compiling; the docs identify each one as an alias to the equivalent `add_hook` call.
16+
17+
**Phase set (eleven, in firing order).** `connection_opened`, `accept_decision`, `request_received`, `body_chunk`, `route_resolved`, `before_handler`, `handler_exception`, `after_handler`, `response_sent`, `request_completed`, `connection_closed`. See §4.10 for the firing-site map.
18+
19+
**Short-circuit semantics.** Both pre-handler and post-handler phases can short-circuit by returning `hook_action::respond_with(response)`. For pre-handler phases (`request_received`, `body_chunk`, `before_handler`, `handler_exception`), short-circuit means: skip remaining hooks at the phase and skip the resource handler. For the post-handler phase `after_handler`, short-circuit means: replace the in-flight response and skip remaining hooks at the phase. `response_sent`, `request_completed`, `connection_opened`, `connection_closed`, `accept_decision`, `route_resolved` are observation-only — they can record state and mutate user-side data, but cannot alter the in-flight response or its delivery.
20+
21+
**Per-route hooks.** `http_resource::add_hook(phase, callable)` registers a hook scoped to dispatches of that resource only. Available for phases that fire after route resolution (`before_handler`, `handler_exception`, `after_handler`, `response_sent`, `request_completed`). Server-wide hooks always run before per-route hooks within the same phase; within each scope, registration order applies. Phases firing before route resolution (`connection_opened`, `accept_decision`, `request_received`, `body_chunk`) are server-wide only.
22+
23+
**Exception propagation.** A throwing hook is caught by the existing DR-009 §5.2 mechanism: log via `log_error`, route to the `handler_exception` hook chain (or the `internal_error_handler` alias as last-position fallback). The hook bus does not introduce a separate error contract.
24+
25+
**Zero-cost when unused.** Each phase has a `std::atomic<bool> any_hooks_` flag. The dispatch site checks the flag with a relaxed load; if false, no `std::function` invocation, no vector iteration, no lock. The cost on a hot path for a server with zero hooks registered is one atomic relaxed load per applicable phase.
26+
27+
**Consequences:**
28+
- New public types: `hook_phase` enum, `hook_action` token, `hook_handle` (RAII), per-phase context structs (`accept_ctx`, `request_received_ctx`, …, `response_sent_ctx`). All libhttpserver-defined; never expose MHD types (PRD-HDR-REQ-001).
29+
- New public methods: `webserver::add_hook(phase, callable)` overloaded per phase; `http_resource::add_hook(phase, callable)` for per-route subscription.
30+
- v1-derived shorthands (`log_access`, `not_found_handler`, `method_not_allowed_handler`, `internal_error_handler`, `auth_handler`) retained on `create_webserver`; Doxygen + README identify each as an alias that internally registers at the corresponding phase.
31+
- `add_hook` and `hook_handle::remove()` are safe to call from inside a hook, mirroring DR-008 §5.1 for `register_resource`.
32+
- Order constraint within a phase: server-wide hook before per-route hook; within each scope, registration order. Documented as the rule.
33+
- Open follow-ups deferred to v2.1: WebSocket upgrade hook, TLS handshake observation, body-buffer steal (#272's "give me back my string" half — needs a separate streaming-body API design).
34+
35+
**Verification:**
36+
- Skeleton + add/remove semantics: TASK-045.
37+
- Per-phase firing tests: one per task, TASK-046 through TASK-051.
38+
- Per-route + ordering invariant: TASK-051.
39+
- Thread-safety stress test extension (concurrent `add_hook` + dispatch): TASK-052.
40+
- Microbenchmark `bench_hook_overhead` (zero-cost-when-unused): TASK-052.
41+
42+
---

0 commit comments

Comments
 (0)