Skip to content

Commit 719d4c3

Browse files
committed
TASK-053 validation iter1: address reviewer findings
- lookup_v2: move canonical path into cache_key to avoid second alloc; reuse key.path for all tier probes. - route_cache::find_by_view: promote via bucket iterator directly, removing a redundant map_.find() on every cache hit. - classify_route_tier: compile regexes with std::regex::nosubs since match-only is needed (capture groups were never consumed). - bench_route_lookup: hoist the cache-hit query path to a static so the timed region measures the lookup, not std::string construction. - v2_dispatch_contract_test: replace fixed 50 ms sleep with wait_for_server_ready helper; add is_prefix assertion on the 405 method-mismatch path; cover this gap explicitly. - route_table_test: add cache_find_by_view_promotes_to_front to pin LRU promotion semantics of find_by_view. - webserver_impl.hpp: refresh the lookup_v2 doc-comment now that it is the sole dispatch-path resolver post-step-3 (the prior NOTE described the pre-cutover shadow-table state). Also capture the iter1 unworked-review-issues report under specs/unworked_review_issues/.
1 parent f033546 commit 719d4c3

8 files changed

Lines changed: 291 additions & 27 deletions

File tree

specs/unworked_review_issues/2026-05-29_112007_task-053.md

Lines changed: 209 additions & 0 deletions
Large diffs are not rendered by default.

src/detail/webserver_dispatch.cpp

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ webserver_impl::lookup_result
141141
webserver_impl::lookup_v2(http_method method, const std::string& path) {
142142
lookup_result result;
143143

144-
const std::string lookup_path = canonicalize_lookup_path(path);
144+
std::string lookup_path = canonicalize_lookup_path(path);
145145

146146
// Step 1: cache. Cache under the canonical key so /foo and /foo/
147147
// share an entry. Use find_by_view to avoid copying lookup_path
@@ -155,16 +155,17 @@ webserver_impl::lookup_v2(http_method method, const std::string& path) {
155155
result.captured_params = std::move(cached.captured_params);
156156
return result;
157157
}
158-
// Construct key only on the miss path, where we need to own the
159-
// string for insertion into the cache.
160-
cache_key key{method, lookup_path};
158+
// Construct key by moving lookup_path into the cache_key, avoiding a
159+
// second heap allocation. All subsequent tier lookups use key.path,
160+
// which is the same std::string now owned by the key.
161+
cache_key key{method, std::move(lookup_path)};
161162

162163
// Step 2: walk the tiers under a shared lock.
163164
{
164165
std::shared_lock table_lock(route_table_mutex_);
165166

166167
// 2a. Exact tier — single hash probe.
167-
auto exact_it = exact_routes_.find(lookup_path);
168+
auto exact_it = exact_routes_.find(key.path);
168169
if (exact_it != exact_routes_.end()) {
169170
result.found = true;
170171
result.tier = tier_hit::exact;
@@ -175,7 +176,7 @@ webserver_impl::lookup_v2(http_method method, const std::string& path) {
175176
// 2b. Radix tier — segment-trie walk.
176177
if (!result.found) {
177178
radix_match<route_entry> rm;
178-
if (param_and_prefix_routes_.find(lookup_path, rm) && rm.entry) {
179+
if (param_and_prefix_routes_.find(key.path, rm) && rm.entry) {
179180
result.found = true;
180181
result.tier = tier_hit::radix;
181182
result.entry = *rm.entry;
@@ -188,7 +189,7 @@ webserver_impl::lookup_v2(http_method method, const std::string& path) {
188189
// and on_methods_), so no compilation cost is paid per lookup.
189190
if (!result.found) {
190191
for (const auto& rr : regex_routes_) {
191-
if (std::regex_match(lookup_path, rr.compiled_re)) {
192+
if (std::regex_match(key.path, rr.compiled_re)) {
192193
result.found = true;
193194
result.tier = tier_hit::regex;
194195
result.entry = rr.entry;

src/httpserver/detail/route_cache.hpp

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,12 @@ class route_cache {
149149
return false;
150150
}
151151
std::size_t b = bucket_hash % map_.bucket_count();
152-
for (auto it = map_.cbegin(b), end = map_.cend(b); it != end; ++it) {
152+
for (auto it = map_.begin(b), end = map_.end(b); it != end; ++it) {
153153
if (it->first.method == method && it->first.path == path) {
154-
// Promote: splice requires a mutable iterator from the main map.
155-
auto main_it = map_.find(it->first);
156-
list_.splice(list_.begin(), list_, main_it->second);
157-
out = main_it->second->second;
154+
// Promote using the mutable bucket iterator directly —
155+
// avoids a second map_.find() call on every cache hit.
156+
list_.splice(list_.begin(), list_, it->second);
157+
out = it->second->second;
158158
return true;
159159
}
160160
}

src/httpserver/detail/route_tier.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ inline route_tier_result classify_route_tier(const detail::http_endpoint& idx) {
8888
std::regex re;
8989
try {
9090
re = std::regex(idx.get_url_normalized(),
91-
std::regex::extended | std::regex::icase);
91+
std::regex::extended | std::regex::icase
92+
| std::regex::nosubs);
9293
} catch (const std::regex_error& e) {
9394
throw std::invalid_argument(
9495
std::string("invalid regex route pattern '")

src/httpserver/detail/webserver_impl.hpp

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -245,12 +245,12 @@ class webserver_impl {
245245
// documented above. Returns lookup_result; populates `tier` even on
246246
// miss (tier_hit::none) so callers can branch deterministically.
247247
//
248-
// NOTE (CWE-284 guard): lookup_v2 is a SHADOW TABLE only. The live
249-
// HTTP dispatch path (finalize_answer -> resolve_resource_for_request)
250-
// still uses the v1 registered_resources* maps. lookup_v2 is used
251-
// by tests to pin the v2 tier pipeline and will be wired into
252-
// finalize_answer at Cycle K dispatch cutover. Until then, any access
253-
// control registered via the v2 table has NO effect on live traffic.
248+
// NOTE (TASK-053): lookup_v2 is the live dispatch path. As of
249+
// TASK-053 step 3, resolve_resource_for_request() calls lookup_v2()
250+
// exclusively; all v1 helper functions were deleted. The v1
251+
// registered_resources* maps survive only as registration-side
252+
// bookkeeping (lambda/class conflict detection and WebSocket dispatch
253+
// — see the 'Registration-side resource maps' section below).
254254
lookup_result lookup_v2(http_method method, const std::string& path);
255255

256256
// TASK-045 -- Lifecycle hook bus (skeleton, no firing yet).

test/bench_route_lookup.cpp

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,19 +195,21 @@ int main() {
195195
auto* impl = hs::webserver_test_access::impl(*ws);
196196

197197
// ----- (a) cache-hit -----
198+
// Pre-allocate the query path outside the timed region so the bench
199+
// measures only the lookup machinery, not std::string construction.
200+
static const std::string kCacheHitPath("/api/v1/users/me");
201+
198202
// First call warms the cache. Subsequent calls hit it.
199203
{
200-
auto warm = impl->lookup_v2(
201-
hs::http_method::get, std::string("/api/v1/users/me"));
204+
auto warm = impl->lookup_v2(hs::http_method::get, kCacheHitPath);
202205
do_not_optimize(warm);
203206
}
204207

205208
std::printf("bench_route_lookup (a): cache-hit (/api/v1/users/me)\n");
206209
const double median_cache_ns = measure_median_ns(
207210
"cache-hit",
208211
[&]() {
209-
auto r = impl->lookup_v2(
210-
hs::http_method::get, std::string("/api/v1/users/me"));
212+
auto r = impl->lookup_v2(hs::http_method::get, kCacheHitPath);
211213
do_not_optimize(r);
212214
},
213215
OUTER, INNER_CACHE);

test/unit/route_table_test.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,28 @@ LT_BEGIN_AUTO_TEST(route_table_suite, radix_tree_remove_one_terminus_does_not_cl
336336
LT_CHECK(m.is_prefix_match);
337337
LT_END_AUTO_TEST(radix_tree_remove_one_terminus_does_not_clear_sibling)
338338

339+
// find_by_view must promote the hit to the front of the LRU list,
340+
// just like find(). After a find_by_view hit on /a, inserting /d into a
341+
// capacity-3 cache must evict the true LRU entry (/b), not /a.
342+
LT_BEGIN_AUTO_TEST(route_table_suite, cache_find_by_view_promotes_to_front)
343+
htd::route_cache cache(3);
344+
htd::cache_value v;
345+
cache.insert({ht::http_method::get, "/a"}, v);
346+
cache.insert({ht::http_method::get, "/b"}, v);
347+
cache.insert({ht::http_method::get, "/c"}, v);
348+
// Promote /a to front via find_by_view.
349+
htd::cache_value out;
350+
std::string_view pv_a{"/a"};
351+
LT_CHECK(cache.find_by_view(ht::http_method::get, pv_a, out));
352+
// Insert /d -> evicts oldest non-A entry (/b).
353+
cache.insert({ht::http_method::get, "/d"}, v);
354+
htd::cache_value probe;
355+
LT_CHECK(cache.find({ht::http_method::get, "/a"}, probe));
356+
LT_CHECK(!cache.find({ht::http_method::get, "/b"}, probe));
357+
LT_CHECK(cache.find({ht::http_method::get, "/c"}, probe));
358+
LT_CHECK(cache.find({ht::http_method::get, "/d"}, probe));
359+
LT_END_AUTO_TEST(cache_find_by_view_promotes_to_front)
360+
339361
LT_BEGIN_AUTO_TEST_ENV()
340362
AUTORUN_TESTS()
341363
LT_END_AUTO_TEST_ENV()

test/unit/v2_dispatch_contract_test.cpp

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,30 @@
5454
#include "./httpserver.hpp"
5555
#include "./littletest.hpp"
5656

57+
// wait_for_server_ready: poll the given port with an HTTP GET / until the
58+
// server responds (any status), or until the deadline elapses. This avoids
59+
// a fixed sleep that is either too short on slow/sanitizer CI builds or
60+
// wastes 50 ms of wall time on every test invocation.
61+
static void wait_for_server_ready(int port,
62+
std::chrono::milliseconds deadline
63+
= std::chrono::milliseconds(3000)) {
64+
using clock = std::chrono::steady_clock;
65+
auto end = clock::now() + deadline;
66+
std::string url = "http://127.0.0.1:" + std::to_string(port) + "/";
67+
while (clock::now() < end) {
68+
CURL* c = curl_easy_init();
69+
if (!c) break;
70+
curl_easy_setopt(c, CURLOPT_URL, url.c_str());
71+
curl_easy_setopt(c, CURLOPT_NOBODY, 1L);
72+
curl_easy_setopt(c, CURLOPT_CONNECTTIMEOUT_MS, 50L);
73+
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, 50L);
74+
CURLcode rc = curl_easy_perform(c);
75+
curl_easy_cleanup(c);
76+
if (rc == CURLE_OK) return;
77+
std::this_thread::sleep_for(std::chrono::milliseconds(5));
78+
}
79+
}
80+
5781
using httpserver::create_webserver;
5882
using httpserver::hook_phase;
5983
using httpserver::http_request;
@@ -148,7 +172,7 @@ LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, parameterized_route_extracts_capt
148172
auto resource = std::make_shared<echo_id_resource>();
149173
ws.register_path("/users/{id}", resource);
150174
ws.start(false);
151-
std::this_thread::sleep_for(std::chrono::milliseconds(50));
175+
wait_for_server_ready(PORT);
152176

153177
response_capture r = do_get("/users/42");
154178
ws.stop();
@@ -182,7 +206,7 @@ LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, prefix_route_marks_is_prefix_true
182206
auto resource = std::make_shared<hello_resource>();
183207
ws.register_prefix("/static", resource);
184208
ws.start(false);
185-
std::this_thread::sleep_for(std::chrono::milliseconds(50));
209+
wait_for_server_ready(PORT);
186210

187211
response_capture r = do_get("/static/foo/bar");
188212
ws.stop();
@@ -217,7 +241,7 @@ LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, exact_route_marks_is_prefix_false
217241
auto resource = std::make_shared<hello_resource>();
218242
ws.register_path("/exact", resource);
219243
ws.start(false);
220-
std::this_thread::sleep_for(std::chrono::milliseconds(50));
244+
wait_for_server_ready(PORT);
221245

222246
response_capture r = do_get("/exact");
223247
ws.stop();
@@ -243,6 +267,8 @@ LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, method_mismatch_still_resolves_ro
243267
if (ctx.matched.has_value()) {
244268
probe.last_matched_engaged.store(true,
245269
std::memory_order_relaxed);
270+
probe.last_is_prefix.store(ctx.matched->is_prefix,
271+
std::memory_order_relaxed);
246272
probe.last_resource_non_null.store(
247273
ctx.resource != nullptr,
248274
std::memory_order_relaxed);
@@ -259,14 +285,17 @@ LT_BEGIN_AUTO_TEST(v2_dispatch_contract_suite, method_mismatch_still_resolves_ro
259285
resource->set_allowing(httpserver::http_method::get, true);
260286
ws.register_path("/get_only", resource);
261287
ws.start(false);
262-
std::this_thread::sleep_for(std::chrono::milliseconds(50));
288+
wait_for_server_ready(PORT);
263289

264290
long post_status = do_post_status("/get_only");
265291
ws.stop();
266292

267293
LT_CHECK_EQ(post_status, 405L);
268294
LT_CHECK(probe.last_matched_engaged.load());
269295
LT_CHECK(probe.last_resource_non_null.load());
296+
// /get_only is an exact (non-prefix) route; is_prefix must be false
297+
// in the 405 code path as well as in the 200 code path.
298+
LT_CHECK_EQ(probe.last_is_prefix.load(), false);
270299
LT_END_AUTO_TEST(method_mismatch_still_resolves_route)
271300

272301
LT_BEGIN_AUTO_TEST_ENV()

0 commit comments

Comments
 (0)