Skip to content

Commit c20cde1

Browse files
etrclaude
andcommitted
TASK-066: strike forward-debt comments; pin alias slots as construction-time-only
Option (b) of the task spec: rather than add a runtime alias setter (no demand signal, two-mechanisms-behind-one-façade asymmetry, DR-012 already frames aliases as construction-time sugar), remove the speculative "if a future task adds a runtime setter..." comments and pin the immutable-after-construction contract with tests and documentation. - src/hook_handle.cpp: trim three forward-debt comment blocks (in erase_slot_for_handler_phase_, fire_handler_exception tail, fire_response_sent tail) to one-line "immutable after start() (DR-012 §4.10)" rationale. - src/detail/webserver_aliases.cpp: trim the install_internal_error_alias call-site comment to the immutable-after-construction one-liner. - src/httpserver/detail/webserver_impl.hpp: trim the lifetime-contract comments on handler_exception_alias_ and log_access_alias_ to drop the speculative "future runtime setter" paragraphs; keep the single-writer-at-construction rationale. - src/httpserver/hook_handle.hpp: add an "Alias slots are construction-time-only" docstring paragraph naming the five v1 aliases and pointing users to add_hook() for runtime extension. - specs/architecture/04-components/hooks.md: add an "Alias mutability" paragraph to §4.10 explicitly stating that v1 aliases are construction-time-only and that the hook bus is the runtime extension surface (per DR-012). - test/unit/hooks_log_access_alias_slot_test.cpp: replace the misleading log_access_second_registration_replaces_first test (the smell TASK-085 finding 3 flagged) with two real contract pins: log_access_alias_is_immutable_after_construction and handler_exception_alias_is_immutable_after_construction. Both verify that user add_hook() at the relevant phase grows the user vector but never displaces the alias slot, and that hook_handle::remove() leaves the alias slot intact. Acceptance: - grep -nE 'if a future task adds a runtime setter for the alias slots' src/ returns no matches. - Broader audit grep 'future task adds a runtime|future runtime setter|runtime re-registration path' src/ also returns no matches. - hooks_log_access_alias_slot now: 7 tests, 29 checks, 0 failures. - All hook-related tests pass; cpplint clean on changed files. - specs/tasks/M7-v2-cleanup/TASK-066.md updated to Done with resolution notes and TASK-085 handoff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent ea7f6a7 commit c20cde1

7 files changed

Lines changed: 125 additions & 79 deletions

File tree

specs/architecture/04-components/hooks.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ hook_handle http_resource::add_hook(hook_phase, std::function<...>);
9797
| `log_error(fn)` | *not* a hook alias — it is an MHD-level callback for backend errors, distinct from the request lifecycle |
9898
| `file_cleanup_callback(fn)` | *not* a hook alias — file-upload cleanup is a separate post-upload concern, not a lifecycle phase |
9999
100+
**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`.
101+
100102
**Related requirements:** PRD-HOOK-REQ-001..009.
101103
**Related decisions:** DR-012, §5.6.
102104

specs/tasks/M7-v2-cleanup/TASK-066.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@
88
Five separate "if a future task adds a runtime setter for the alias slots, this MUST take the lock shared" comments cluster across `src/hook_handle.cpp:142, 449, 497` and `src/httpserver/detail/webserver_impl.hpp:336, 360`. All point at the same missing capability: runtime re-registration of internal alias slots (`log_access`, `not_found_handler`, `method_not_allowed_handler`, `internal_error_handler`, `auth_handler`). Land that capability or strike the speculation.
99

1010
**Action Items:**
11-
- [ ] Decide: (a) add a `webserver::set_alias(hook_phase, alias_id, hook_action_fn)` runtime setter that re-points the relevant alias slot under the existing `shared_mutex`, with replacement semantics matching the construction-time alias (last-position, single-slot), or (b) remove the forward-debt comments and pin "aliases are immutable after `webserver::start()`" in the docs.
12-
- [ ] If (a): implement the setter, document it in the architecture doc (§4.10), add a hook-bus integration test covering concurrent alias replacement under load, and remove all five forward-debt comments.
13-
- [ ] If (b): remove the five comments and add a documentation note (in `hook_handle.hpp` and the §4.10 architecture page) that alias slots are construction-time-only.
14-
- [ ] Update `test/unit/hooks_log_access_alias_slot_test.cpp` to reflect the chosen semantics — coordinated with TASK-085.
11+
- [x] Decide: (a) add a `webserver::set_alias(hook_phase, alias_id, hook_action_fn)` runtime setter that re-points the relevant alias slot under the existing `shared_mutex`, with replacement semantics matching the construction-time alias (last-position, single-slot), or (b) remove the forward-debt comments and pin "aliases are immutable after `webserver::start()`" in the docs. **→ Option (b) selected. Rationale: zero demand signal (no examples, no PRD, no test reaches for runtime replacement); option (a) would hide two different mechanisms behind one façade (`log_access`/`internal_error_handler` are single-slot members; `auth_handler`/`not_found_handler`/`method_not_allowed_handler` are detached vector entries); DR-012 already framed aliases as "documented sugar that internally registers a hook" with no "open follow-ups" entry for runtime replacement; the public runtime extension surface IS `add_hook()` + `hook_handle`. See `.groundwork-plans/TASK-066-plan.md` for the full plan.**
12+
- [x] If (a): ~~implement the setter, document it in the architecture doc (§4.10), add a hook-bus integration test covering concurrent alias replacement under load, and remove all five forward-debt comments.~~ Not selected.
13+
- [x] If (b): remove the five comments and add a documentation note (in `hook_handle.hpp` and the §4.10 architecture page) that alias slots are construction-time-only. **Done.** Removed seven comment blocks (the spec's five plus two more found in `webserver_aliases.cpp` and the duplicated `fire_handler_exception` tail). Added the docstring paragraph to `hook_handle.hpp` and the "Alias mutability" paragraph to §4.10.
14+
- [x] Update `test/unit/hooks_log_access_alias_slot_test.cpp` to reflect the chosen semantics — coordinated with TASK-085. **Done.** Replaced the misleading `log_access_second_registration_replaces_first` test with two real contract pins: `log_access_alias_is_immutable_after_construction` and `handler_exception_alias_is_immutable_after_construction`. TASK-085's action item 3 ("update this test once TASK-066 ships") is satisfied here.
1515

1616
**Dependencies:**
1717
- Blocked by: TASK-045 (hook bus skeleton, Done)
@@ -27,4 +27,4 @@ Five separate "if a future task adds a runtime setter for the alias slots, this
2727
**Related Requirements:** PRD-HOOK-REQ-009 (v1 setters documented as aliases)
2828
**Related Decisions:** DR-012
2929

30-
**Status:** Backlog
30+
**Status:** Done

src/detail/webserver_aliases.cpp

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -342,14 +342,10 @@ void webserver::install_default_alias_hooks_() {
342342
// calling it a second time would observably invoke the user code
343343
// twice for one logical exception). See webserver_dispatch.cpp.
344344
//
345-
// Re-registration semantics (finding #35 / spec-alignment): the
346-
// task spec says "re-registration replaces the existing alias hook".
347-
// At v2.0 there is no runtime re-registration path -- the slot is
348-
// written exactly once at construction (write-once-at-construction
349-
// contract). If a future task adds a runtime setter, it must
350-
// overwrite handler_exception_alias_ under hook_table_mutex_
351-
// exclusive and update any_hooks_ accordingly; "replace" semantics
352-
// are then enforced by the plain std::function overwrite.
345+
// The alias slot is written exactly once here and is immutable
346+
// thereafter (DR-012 / §4.10). Runtime extension of the
347+
// handler_exception phase is via add_hook(); the alias slot is not
348+
// user-mutable post-construction.
353349
install_internal_error_alias(impl_.get(), internal_error_handler);
354350

355351
// ----------------------------------------------------------------

src/hook_handle.cpp

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,11 @@ void erase_slot_for_handler_phase_(detail::webserver_impl* impl,
139139
case hook_phase::before_handler:
140140
erase_and_reset(impl->hooks_before_handler_); break;
141141
case hook_phase::handler_exception:
142-
// Finding #6 (forward-looking): if a future task adds a runtime
143-
// re-registration path for handler_exception_alias_, remove()
144-
// will also need a path to clear that slot and re-evaluate
145-
// any_hooks_. Currently any_hooks_ remains true while the alias
146-
// is wired; removing only a user-vector entry does not clear it
147-
// if the alias is still set. Add that handling alongside the
148-
// runtime setter.
142+
// The alias slot (handler_exception_alias_) is immutable after
143+
// construction (DR-012 / §4.10), so remove() only touches the
144+
// user vector here; any_hooks_ remains true while the alias is
145+
// wired, which is correct -- the alias is the "still has a
146+
// hook" signal.
149147
erase_and_reset(impl->hooks_handler_exception_); break;
150148
case hook_phase::after_handler:
151149
erase_and_reset(impl->hooks_after_handler_); break;
@@ -493,15 +491,9 @@ detail::webserver_impl::fire_handler_exception(
493491
log_dispatch_error(
494492
"fire_handler_exception: snapshot copy failed");
495493
}
496-
// Tail: invoke the alias slot, if any. Read without synchronisation;
497-
// the slot is single-writer-at-construction (see webserver_impl.hpp).
498-
//
499-
// Finding #33 (security-reviewer): if a future task adds a runtime
500-
// setter for handler_exception_alias_, this read MUST be upgraded to
501-
// take hook_table_mutex_ shared and the writer MUST take it exclusive.
502-
// The synchronisation contract lives in webserver_impl.hpp at the
503-
// handler_exception_alias_ field declaration -- keep both sites in
504-
// sync when adding any runtime re-registration path.
494+
// Tail: invoke the alias slot, if any. Read without synchronisation:
495+
// the slot is single-writer-at-construction, immutable after
496+
// webserver::start() (DR-012 / §4.10).
505497
//
506498
// Throw containment: a throwing alias is logged with the legacy
507499
// "internal_error_handler threw" prefix so the DR-009 §5.2 point 4
@@ -545,8 +537,9 @@ detail::webserver_impl::fire_after_handler(
545537
void detail::webserver_impl::fire_response_sent(
546538
const ::httpserver::response_sent_ctx& ctx) noexcept {
547539
fire_hooks_for_phase(this, hooks_response_sent_, ctx, "response_sent");
548-
// Tail: invoke the alias slot, if any. Read without synchronisation;
549-
// the slot is single-writer-at-construction (see webserver_impl.hpp).
540+
// Tail: invoke the alias slot, if any. Read without synchronisation:
541+
// the slot is single-writer-at-construction, immutable after
542+
// webserver::start() (DR-012 / §4.10).
550543
if (log_access_alias_) {
551544
try {
552545
log_access_alias_(ctx);

src/httpserver/detail/webserver_impl.hpp

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -347,12 +347,8 @@ class webserver_impl {
347347
// at webserver construction, before start() is called -- the daemon is
348348
// not yet running, so no synchronisation is required for the write.
349349
// Read on the dispatch hot path from fire_handler_exception with no
350-
// lock (single-writer-before-readers contract, same as
351-
// parent->internal_error_handler). If a future task adds a runtime
352-
// setter the writer MUST take hook_table_mutex_ exclusively and the
353-
// reader MUST take it shared -- same pattern as the per-phase vectors.
354-
// (Finding #14: full rationale lives here; fire_handler_exception in
355-
// hook_handle.cpp has a cross-reference comment per finding #33.)
350+
// lock. Slot is immutable after construction (DR-012 / §4.10); the
351+
// reader path requires no synchronisation.
356352
std::function<::httpserver::hook_action(
357353
const ::httpserver::handler_exception_ctx&)>
358354
handler_exception_alias_;
@@ -370,11 +366,8 @@ class webserver_impl {
370366
// The slot fires AFTER user-added response_sent hooks so user hooks
371367
// observe the response before the legacy access logger formats it.
372368
//
373-
// A future runtime setter that allows re-registration MUST take
374-
// hook_table_mutex_ exclusively for the write, and the reader in
375-
// fire_response_sent MUST take it shared. At v2.0 there is no runtime
376-
// setter -- the slot is written exactly once at webserver construction
377-
// before start() is called.
369+
// Slot is immutable after construction (DR-012 / §4.10); the reader
370+
// path requires no synchronisation.
378371
std::function<void(const ::httpserver::response_sent_ctx&)>
379372
log_access_alias_;
380373
std::vector<phase_entry<void(const ::httpserver::request_completed_ctx&)>>

src/httpserver/hook_handle.hpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ class http_resource;
8282
* `weak_ptr<resource_hook_table>` instead of a raw pointer; their
8383
* `remove()` is safe even after the resource is destroyed (the
8484
* weak_ptr expires and the call becomes a no-op).
85+
*
86+
* @note **Alias slots are construction-time-only.** The v1-derived
87+
* setters on `create_webserver` (`log_access`,
88+
* `internal_error_handler`, `not_found_handler`,
89+
* `method_not_allowed_handler`, `auth_handler`) wire their slot
90+
* exactly once during webserver construction and are immutable
91+
* thereafter. To add or remove an observation/intervention point at
92+
* runtime, use `webserver::add_hook(phase, ...)` and the returned
93+
* `hook_handle`. See DR-012 / §4.10.
8594
*/
8695
class hook_handle {
8796
public:

test/unit/hooks_log_access_alias_slot_test.cpp

Lines changed: 90 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
#include <algorithm>
4040
#include <functional>
4141
#include <string>
42+
#include <string_view>
4243

4344
#include "./httpserver.hpp"
4445
#include "httpserver/create_test_request.hpp"
@@ -164,45 +165,97 @@ LT_BEGIN_AUTO_TEST(hooks_log_access_alias_slot_suite,
164165
LT_CHECK(captured.find("GET") != std::string::npos);
165166
LT_END_AUTO_TEST(alias_sanitizes_control_chars_in_method)
166167

167-
// Re-registration: calling log_access a second time on the builder
168-
// replaces the previous callable. At v2.0 the only write point is
169-
// webserver construction (write-once-at-construction contract); runtime
170-
// re-registration via a setter is deferred to a future task.
171-
// This test pins the replace semantics at construction time.
168+
// TASK-066: alias slots are construction-time-only. The previous test on
169+
// this site (`log_access_second_registration_replaces_first`) simulated
170+
// re-registration by constructing two webservers and asserting they did
171+
// not pollute each other -- but that was not a runtime "replace"
172+
// semantics pin, because no runtime replacement path exists or is
173+
// planned (DR-012, §4.10). The two tests below replace that simulated
174+
// scenario with the real contract: alias slots are written exactly once
175+
// at webserver construction and are immutable thereafter; the public
176+
// runtime extension surface for both phases is add_hook(), which lives
177+
// in the per-phase user vector and never touches the alias slot.
172178
LT_BEGIN_AUTO_TEST(hooks_log_access_alias_slot_suite,
173-
log_access_second_registration_replaces_first)
174-
int first_calls = 0;
175-
int second_calls = 0;
176-
// Simulate re-registration by creating two builders and checking that
177-
// only the callable set on the webserver used for construction is stored.
178-
webserver ws1{create_webserver(8244)
179-
.log_access([&first_calls](const std::string&) { ++first_calls; })};
180-
webserver ws2{create_webserver(8245)
181-
.log_access([&second_calls](const std::string&) { ++second_calls; })};
182-
183-
// Each webserver holds its own callable; neither pollutes the other.
184-
auto* impl1 = impl_of(ws1);
185-
auto* impl2 = impl_of(ws2);
186-
LT_CHECK(static_cast<bool>(impl1->log_access_alias_));
187-
LT_CHECK(static_cast<bool>(impl2->log_access_alias_));
188-
189-
// Invoke each alias independently.
190-
http_request req1 =
179+
log_access_alias_is_immutable_after_construction)
180+
int direct_calls = 0;
181+
int user_hook_calls = 0;
182+
webserver ws{create_webserver(8244)
183+
.log_access([&direct_calls](const std::string&) { ++direct_calls; })};
184+
auto* impl = impl_of(ws);
185+
186+
// (a) Construction wires the slot exactly once.
187+
LT_CHECK(static_cast<bool>(impl->log_access_alias_));
188+
LT_CHECK_EQ(impl->hooks_response_sent_.size(),
189+
static_cast<std::size_t>(0));
190+
191+
// (b) Adding a user response_sent hook grows the vector but leaves
192+
// the alias slot untouched -- the public way to add observation at
193+
// response_sent post-construction is add_hook(), which routes into
194+
// the user vector and never reseats the alias slot.
195+
auto h = ws.add_hook(hook_phase::response_sent,
196+
std::function<void(const response_sent_ctx&)>(
197+
[&user_hook_calls](const response_sent_ctx&) {
198+
++user_hook_calls;
199+
}));
200+
LT_CHECK(static_cast<bool>(impl->log_access_alias_));
201+
LT_CHECK_EQ(impl->hooks_response_sent_.size(),
202+
static_cast<std::size_t>(1));
203+
204+
// (c) Removing the user hook leaves the alias slot untouched -- the
205+
// slot is not under user control via the hook bus.
206+
h.remove();
207+
LT_CHECK(static_cast<bool>(impl->log_access_alias_));
208+
LT_CHECK_EQ(impl->hooks_response_sent_.size(),
209+
static_cast<std::size_t>(0));
210+
211+
// (d) Direct invocation still reaches the construction-time callable.
212+
http_request req =
191213
create_test_request().path("/a").method("GET").build();
192-
http_request req2 =
193-
create_test_request().path("/b").method("GET").build();
194-
response_sent_ctx ctx1{};
195-
ctx1.request = &req1;
196-
response_sent_ctx ctx2{};
197-
ctx2.request = &req2;
198-
199-
impl1->log_access_alias_(ctx1);
200-
impl2->log_access_alias_(ctx2);
201-
202-
// Only the callable stored on each respective webserver was invoked.
203-
LT_CHECK_EQ(first_calls, 1);
204-
LT_CHECK_EQ(second_calls, 1);
205-
LT_END_AUTO_TEST(log_access_second_registration_replaces_first)
214+
response_sent_ctx ctx{};
215+
ctx.request = &req;
216+
impl->log_access_alias_(ctx);
217+
LT_CHECK_EQ(direct_calls, 1);
218+
LT_CHECK_EQ(user_hook_calls, 0);
219+
LT_END_AUTO_TEST(log_access_alias_is_immutable_after_construction)
220+
221+
LT_BEGIN_AUTO_TEST(hooks_log_access_alias_slot_suite,
222+
handler_exception_alias_is_immutable_after_construction)
223+
// Mirror of the log_access pin above for handler_exception_alias_.
224+
// Construction wires the slot; user add_hook() at handler_exception
225+
// grows hooks_handler_exception_ but does not displace or clear the
226+
// alias slot. Removing the user hook leaves the alias slot intact.
227+
webserver ws{create_webserver(8244)
228+
.internal_error_handler(
229+
[](const httpserver::http_request&, std::string_view)
230+
-> httpserver::http_response {
231+
return httpserver::http_response::string("oops");
232+
})};
233+
auto* impl = impl_of(ws);
234+
235+
// (a) Construction wires the alias slot exactly once.
236+
LT_CHECK(static_cast<bool>(impl->handler_exception_alias_));
237+
LT_CHECK_EQ(impl->hooks_handler_exception_.size(),
238+
static_cast<std::size_t>(0));
239+
240+
// (b) User add_hook(handler_exception, ...) grows the vector; alias
241+
// slot remains set independently.
242+
auto h = ws.add_hook(hook_phase::handler_exception,
243+
std::function<httpserver::hook_action(
244+
const httpserver::handler_exception_ctx&)>(
245+
[](const httpserver::handler_exception_ctx&) {
246+
return httpserver::hook_action::pass();
247+
}));
248+
LT_CHECK(static_cast<bool>(impl->handler_exception_alias_));
249+
LT_CHECK_EQ(impl->hooks_handler_exception_.size(),
250+
static_cast<std::size_t>(1));
251+
252+
// (c) Removing the user hook does not clear the alias slot -- the
253+
// slot is not under user control via the hook bus.
254+
h.remove();
255+
LT_CHECK(static_cast<bool>(impl->handler_exception_alias_));
256+
LT_CHECK_EQ(impl->hooks_handler_exception_.size(),
257+
static_cast<std::size_t>(0));
258+
LT_END_AUTO_TEST(handler_exception_alias_is_immutable_after_construction)
206259

207260
LT_BEGIN_AUTO_TEST_ENV()
208261
AUTORUN_TESTS()

0 commit comments

Comments
 (0)