Skip to content

Commit ce13c96

Browse files
etrclaude
andcommitted
TASK-071 (A): wire install_not_found_alias_ and pin v1-404 behaviour
The install_not_found_alias_ hook body was previously labelled "structurally deferred (TASK-048)". TASK-048 shipped; the deferral comment was a misread of the design pinned in DR-012 §4.10 — route_resolved is observation-only, so the on-wire 404 body intentionally flows through webserver_impl::not_found_page (the v1 call site), and the alias seat at this phase exists as the architectural anchor required by PRD-HOOK-REQ-009 (v1 setters register as hook-bus subscriptions). The body is upgraded from a stub-with-debt-comment to a documented no-op observation marker that explicitly does NOT re-invoke the user handler (avoiding double-counting of v1 observed call rates). Pin the wire contract end-to-end with hooks_not_found_alias_test: - default branch (no explicit handler) returns 404 "Not Found", - custom branch returns the user handler's body once per miss, - a user route_resolved observer co-fires alongside the alias seat (DR-012 §4.10 ordering — observation phase is not short-circuit- capable). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 12bcbb8 commit ce13c96

3 files changed

Lines changed: 219 additions & 14 deletions

File tree

src/detail/webserver_aliases.cpp

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -283,28 +283,47 @@ void webserver::install_method_not_allowed_alias_() {
283283
}
284284

285285
// ----------------------------------------------------------------
286-
// not_found_handler -> route_resolved (observation-only per DR-012).
286+
// not_found_handler -> route_resolved (observation-only per DR-012 §4.10).
287287
//
288288
// This is an alias. Calling not_found_handler(fn) registers a hook
289289
// at hook_phase::route_resolved. Equivalent to
290290
// ws.add_hook(hook_phase::route_resolved, ...).
291291
//
292-
// Structural note: route_resolved_ctx does not carry a mutable
293-
// response slot, so the 404 synthesis for user-provided handlers
294-
// remains in webserver_impl::not_found_page (called from
295-
// finalize_answer). The hook registers the alias in the bus so the
296-
// hook count is observable and the architectural seat is reserved.
292+
// Structural pin: per DR-012 §4.10, route_resolved is observation-only
293+
// — it cannot mutate the in-flight response or its delivery, and
294+
// route_resolved_ctx exposes no mutable response slot. The user-
295+
// provided not_found_handler is therefore consulted at the v1 call
296+
// site webserver_impl::not_found_page (invoked from finalize_answer
297+
// and the materialize-fallback path; see src/detail/webserver_error_pages.cpp
298+
// and src/detail/webserver_request.cpp). The alias seat here is the
299+
// architectural anchor: it reserves a stable hook[0] index at this
300+
// phase (per PRD-HOOK-REQ-009), it keeps the hook count observable
301+
// via the public hook API (verified by hooks_alias_count_test), and
302+
// it gives future observation-only integrations (logging, metrics)
303+
// a known phase boundary to subscribe alongside. The on-wire 404
304+
// body shape is pinned by hooks_not_found_alias_test (default and
305+
// custom branches) and by basic.cpp:custom_not_found_handler.
306+
//
307+
// TASK-071: previously labelled "structurally deferred (TASK-048)".
308+
// TASK-048 shipped; the deferral was a misread of the design — the
309+
// phase being observation-only is not a deferral, it IS the design
310+
// (see DR-012 §4.10). The body remains a documented no-op observation
311+
// marker; the forward-debt comment has been removed.
297312
void webserver::install_not_found_alias_() {
298313
if (not_found_handler == nullptr) return;
299314
add_hook(hook_phase::route_resolved,
300315
std::function<void(const route_resolved_ctx&)>(
301-
[](const route_resolved_ctx&) {
302-
// Observation stub. The actual 404 synthesis lives
303-
// in webserver_impl::not_found_page, consulted from
304-
// finalize_answer at the existing v1 call site.
305-
// Route_resolved_ctx does not carry mr->response so
306-
// the spec's "stash into mr->response" is structurally
307-
// deferred (see TASK-048 spec §action item 3 note).
316+
[](const route_resolved_ctx& ctx) {
317+
// Pure observation marker. The on-wire 404 body is
318+
// synthesised by webserver_impl::not_found_page; this
319+
// hook intentionally does NOT re-invoke the user
320+
// handler (doing so would double-count the user
321+
// handler's call rate and violate v1 observed-call-
322+
// count semantics). DR-012 §4.10 forbids mutating the
323+
// response from this phase. The discard of `ctx` makes
324+
// explicit that no response-shaping decision is taken
325+
// here.
326+
(void) ctx;
308327
}))
309328
.detach();
310329
}

test/Makefile.am

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ LDADD += -lcurl
2626

2727
AM_CPPFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/src/httpserver/ -DHTTPSERVER_COMPILATION
2828
METASOURCES = AUTO
29-
check_PROGRAMS = basic file_upload http_utils threaded nodelay string_utilities http_endpoint ban_system ws_start_stop authentication digest_challenge_format deferred http_resource http_response create_webserver create_webserver_explicit new_response_types daemon_info uri_log feature_unavailable header_hygiene_iovec header_hygiene iovec_entry http_method constants body http_response_sbo http_response_factories http_response_digest_factory http_response_move_sanitizer webserver_pimpl http_request_pimpl create_test_request http_request_arena http_request_const_getters http_request_tls_accessors http_request_operator_stream webserver_register_smartptr webserver_register_path_prefix webserver_on_methods webserver_route route_table lookup_pipeline route_table_concurrency routing_regression route_lookup_canonicalize auth_skip_normalize http_resource_allow_cache v2_dispatch_contract threadsafety_stress webserver_features webserver_ws_unavailable webserver_register_ws_smartptr webserver_dauth_unavailable consumer_fixture header_hygiene_hooks hook_api_shape hooks_no_firing hooks_accept_ctx_shape hooks_connection_lifecycle hooks_accept_decision_banned hooks_accept_decision_throwing hooks_body_chunk_ctx_shape hooks_request_received_short_circuit hooks_body_chunk_observes_progress hooks_body_chunk_short_circuit_no_leak hooks_before_handler_ctx_shape hooks_route_resolved_miss_and_hit hooks_before_handler_short_circuit hooks_alias_count hooks_alias_functional hooks_handler_exception_chain hooks_handler_exception_user_handler_throws_continues_chain hooks_handler_exception_fallback_to_hardcoded_500 hooks_handler_exception_slot hooks_response_sent_ctx_shape hooks_request_completed_ctx_shape hooks_after_handler_replaces_response hooks_after_handler_mutates_response_in_place hooks_response_sent_carries_status_bytes_timing hooks_request_completed_fires_on_early_failure hooks_log_access_alias_slot hooks_per_route_invalid_phase_throws hooks_per_route_order hooks_per_route_early_413_per_endpoint hooks_per_route_resource_destroyed_first hooks_per_route_concurrent_registration auth_handler_optional_signature no_v1_compat_shim cookie_header_sentinel cookie_render cookie_deprecation_sentinel http_response_cookie_wire http_request_cookies_parsed peer_address_to_string secure_zero_dce connection_state_sentinel connection_state_body_residue
29+
check_PROGRAMS = basic file_upload http_utils threaded nodelay string_utilities http_endpoint ban_system ws_start_stop authentication digest_challenge_format deferred http_resource http_response create_webserver create_webserver_explicit new_response_types daemon_info uri_log feature_unavailable header_hygiene_iovec header_hygiene iovec_entry http_method constants body http_response_sbo http_response_factories http_response_digest_factory http_response_move_sanitizer webserver_pimpl http_request_pimpl create_test_request http_request_arena http_request_const_getters http_request_tls_accessors http_request_operator_stream webserver_register_smartptr webserver_register_path_prefix webserver_on_methods webserver_route route_table lookup_pipeline route_table_concurrency routing_regression route_lookup_canonicalize auth_skip_normalize http_resource_allow_cache v2_dispatch_contract threadsafety_stress webserver_features webserver_ws_unavailable webserver_register_ws_smartptr webserver_dauth_unavailable consumer_fixture header_hygiene_hooks hook_api_shape hooks_no_firing hooks_accept_ctx_shape hooks_connection_lifecycle hooks_accept_decision_banned hooks_accept_decision_throwing hooks_body_chunk_ctx_shape hooks_request_received_short_circuit hooks_body_chunk_observes_progress hooks_body_chunk_short_circuit_no_leak hooks_before_handler_ctx_shape hooks_route_resolved_miss_and_hit hooks_before_handler_short_circuit hooks_alias_count hooks_alias_functional hooks_handler_exception_chain hooks_handler_exception_user_handler_throws_continues_chain hooks_handler_exception_fallback_to_hardcoded_500 hooks_handler_exception_slot hooks_response_sent_ctx_shape hooks_request_completed_ctx_shape hooks_after_handler_replaces_response hooks_after_handler_mutates_response_in_place hooks_response_sent_carries_status_bytes_timing hooks_request_completed_fires_on_early_failure hooks_log_access_alias_slot hooks_per_route_invalid_phase_throws hooks_per_route_order hooks_per_route_early_413_per_endpoint hooks_per_route_resource_destroyed_first hooks_per_route_concurrent_registration hooks_not_found_alias auth_handler_optional_signature no_v1_compat_shim cookie_header_sentinel cookie_render cookie_deprecation_sentinel http_response_cookie_wire http_request_cookies_parsed peer_address_to_string secure_zero_dce connection_state_sentinel connection_state_body_residue
3030

3131
MOSTLYCLEANFILES = *.gcda *.gcno *.gcov
3232

@@ -497,6 +497,13 @@ no_v1_compat_shim_SOURCES = unit/no_v1_compat_shim_test.cpp
497497
# that short-circuits the chain (not a no-op stub): a user hook registered
498498
# after the alias does NOT fire for method-not-allowed requests.
499499
hooks_alias_functional_SOURCES = integ/hooks_alias_functional_test.cpp
500+
# TASK-071 sub-item A. End-to-end pin for the not_found_handler ->
501+
# route_resolved alias seat: default-branch "Not Found" body, custom-branch
502+
# user-handler body, and DR-012 §4.10 ordering (a user route_resolved hook
503+
# co-fires alongside the alias seat — observation phase is not short-
504+
# circuit-capable). Cross-pins the wire contract so a future structural
505+
# refactor of install_not_found_alias_ cannot silently regress it.
506+
hooks_not_found_alias_SOURCES = integ/hooks_not_found_alias_test.cpp
500507

501508
# TASK-049 -- handler_exception firing site and internal_error_handler
502509
# alias slot. Adds:
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
This file is part of libhttpserver
3+
Copyright (C) 2011-2026 Sebastiano Merlino
4+
*/
5+
6+
// TASK-071: end-to-end pin for the not_found_handler -> route_resolved
7+
// alias seat.
8+
//
9+
// Sub-item A from TASK-071 — replace the empty stub callback in
10+
// webserver::install_not_found_alias_ with the wired observation hook
11+
// documented per DR-012 §4.10. The on-wire 404 body still flows through
12+
// webserver_impl::not_found_page (route_resolved is observation-only;
13+
// the response slot is not exposed at this phase), but the alias seat
14+
// in the bus must be a real hook so:
15+
//
16+
// (1) PRD-HOOK-REQ-009: v1 setters expose themselves as hook-bus
17+
// subscriptions (verified by hooks_alias_count_test already; we
18+
// cross-pin the on-wire behaviour here).
19+
// (2) AC-1: a fresh webserver with no explicit not_found_handler
20+
// returns 404 with the canonical "Not Found" body.
21+
// (3) AC-1 (custom branch): a webserver with a user not_found_handler
22+
// returns 404 with the user-supplied body on a miss path.
23+
// (4) DR-012 §4.10 ordering pin: user-registered route_resolved hooks
24+
// still fire alongside the alias seat (observation phase is not
25+
// short-circuit-capable). A user observation hook must still see
26+
// the miss.
27+
28+
#include <curl/curl.h>
29+
30+
#include <atomic>
31+
#include <chrono>
32+
#include <functional>
33+
#include <memory>
34+
#include <string>
35+
#include <thread>
36+
37+
#include "./httpserver.hpp"
38+
#include "./littletest.hpp"
39+
40+
using httpserver::create_webserver;
41+
using httpserver::hook_phase;
42+
using httpserver::http_request;
43+
using httpserver::http_response;
44+
using httpserver::route_resolved_ctx;
45+
using httpserver::webserver;
46+
47+
#define PORT_DEFAULT 8211
48+
#define PORT_CUSTOM 8212
49+
#define PORT_ORDER 8213
50+
51+
namespace {
52+
53+
size_t writefunc(void* ptr, size_t size, size_t nmemb, std::string* s) {
54+
s->append(reinterpret_cast<char*>(ptr), size * nmemb);
55+
return size * nmemb;
56+
}
57+
58+
CURLcode get_url(int port, const std::string& path,
59+
long* http_code, // NOLINT(runtime/int)
60+
std::string* body) {
61+
CURL* curl = curl_easy_init();
62+
if (curl == nullptr) return CURLE_FAILED_INIT;
63+
std::string url = "http://127.0.0.1:" + std::to_string(port) + path;
64+
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
65+
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
66+
curl_easy_setopt(curl, CURLOPT_WRITEDATA, body);
67+
CURLcode rc = curl_easy_perform(curl);
68+
if (http_code != nullptr) {
69+
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, http_code);
70+
}
71+
curl_easy_cleanup(curl);
72+
return rc;
73+
}
74+
75+
} // namespace
76+
77+
LT_BEGIN_SUITE(hooks_not_found_alias_suite)
78+
void set_up() {}
79+
void tear_down() {}
80+
LT_END_SUITE(hooks_not_found_alias_suite)
81+
82+
// AC-1, default branch. With no explicit not_found_handler, a request
83+
// for a missing route returns 404 with the canonical "Not Found" body.
84+
// Pinned end-to-end so any future structural refactor of
85+
// install_not_found_alias_ cannot silently regress the wire contract.
86+
LT_BEGIN_AUTO_TEST(hooks_not_found_alias_suite,
87+
not_found_alias_default_body_when_no_user_handler)
88+
webserver ws{create_webserver(PORT_DEFAULT)};
89+
ws.start(false);
90+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
91+
92+
long code = 0; // NOLINT(runtime/int)
93+
std::string body;
94+
LT_CHECK_EQ(get_url(PORT_DEFAULT, "/nonexistent", &code, &body), CURLE_OK);
95+
96+
ws.stop();
97+
98+
LT_CHECK_EQ(code, 404L);
99+
// constants::NOT_FOUND_ERROR == "Not Found" (src/httpserver/constants.hpp:51).
100+
LT_CHECK_EQ(body, std::string("Not Found"));
101+
LT_END_AUTO_TEST(not_found_alias_default_body_when_no_user_handler)
102+
103+
// AC-1, custom branch. With an explicit not_found_handler, a request for
104+
// a missing route returns 404 with the user-supplied body. The alias
105+
// seat is wired (verified separately by hooks_alias_count_test), and the
106+
// on-wire 404 body flows through webserver_impl::not_found_page calling
107+
// the same handler — the user handler is invoked exactly once per miss
108+
// (not double-counted by the alias hook body, which is observation-only).
109+
LT_BEGIN_AUTO_TEST(hooks_not_found_alias_suite,
110+
not_found_alias_invokes_user_handler_on_miss_with_v1_body)
111+
std::atomic<int> handler_calls{0};
112+
auto user_not_found = [&handler_calls](const http_request&) {
113+
handler_calls.fetch_add(1, std::memory_order_relaxed);
114+
return http_response::string("CUSTOM_404").with_status(404);
115+
};
116+
117+
webserver ws{create_webserver(PORT_CUSTOM).not_found_handler(user_not_found)};
118+
ws.start(false);
119+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
120+
121+
long code = 0; // NOLINT(runtime/int)
122+
std::string body;
123+
LT_CHECK_EQ(get_url(PORT_CUSTOM, "/nonexistent", &code, &body), CURLE_OK);
124+
125+
ws.stop();
126+
127+
LT_CHECK_EQ(code, 404L);
128+
LT_CHECK_EQ(body, std::string("CUSTOM_404"));
129+
// The user handler fires exactly once. The alias hook body is a
130+
// pure observation marker (DR-012 §4.10); it does NOT re-invoke
131+
// the user handler. The on-wire body comes from not_found_page.
132+
LT_CHECK_EQ(handler_calls.load(), 1);
133+
LT_END_AUTO_TEST(not_found_alias_invokes_user_handler_on_miss_with_v1_body)
134+
135+
// DR-012 §4.10 ordering pin. The alias seat installs hook[0] at
136+
// route_resolved when not_found_handler is set; a user-added observation
137+
// hook becomes hook[1]. Both fire on a miss (observation phase is not
138+
// short-circuit-capable). Cross-pins that the alias upgrade did not
139+
// accidentally suppress co-registered user hooks.
140+
LT_BEGIN_AUTO_TEST(hooks_not_found_alias_suite,
141+
not_found_alias_does_not_suppress_user_route_resolved_hook)
142+
std::atomic<int> user_observer_calls{0};
143+
std::atomic<int> user_observer_misses{0};
144+
auto user_not_found = [](const http_request&) {
145+
return http_response::string("CUSTOM_404").with_status(404);
146+
};
147+
148+
webserver ws{create_webserver(PORT_ORDER).not_found_handler(user_not_found)};
149+
150+
auto h = ws.add_hook(hook_phase::route_resolved,
151+
std::function<void(const route_resolved_ctx&)>(
152+
[&user_observer_calls, &user_observer_misses]
153+
(const route_resolved_ctx& ctx) {
154+
user_observer_calls.fetch_add(1, std::memory_order_relaxed);
155+
if (!ctx.matched.has_value()) {
156+
user_observer_misses.fetch_add(1,
157+
std::memory_order_relaxed);
158+
}
159+
}));
160+
161+
ws.start(false);
162+
std::this_thread::sleep_for(std::chrono::milliseconds(50));
163+
164+
long code = 0; // NOLINT(runtime/int)
165+
std::string body;
166+
LT_CHECK_EQ(get_url(PORT_ORDER, "/nonexistent", &code, &body), CURLE_OK);
167+
168+
ws.stop();
169+
170+
LT_CHECK_EQ(code, 404L);
171+
LT_CHECK_EQ(body, std::string("CUSTOM_404"));
172+
// User observation hook fired once on the miss path.
173+
LT_CHECK_EQ(user_observer_calls.load(), 1);
174+
LT_CHECK_EQ(user_observer_misses.load(), 1);
175+
LT_END_AUTO_TEST(not_found_alias_does_not_suppress_user_route_resolved_hook)
176+
177+
LT_BEGIN_AUTO_TEST_ENV()
178+
AUTORUN_TESTS()
179+
LT_END_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)