Skip to content

Commit 5d49d42

Browse files
etrclaude
andcommitted
TASK-036 review: condition variable for sentinel test + comment trim
- Replace busy-poll (100×10 ms) in deferred_producer_destroyed_in_ request_completed with a condition_variable-based wait (finding 11: test-quality-reviewer). The destruction_sentinel now holds a mutex/cv pair and signals on destruction; the test does a timed_wait with a 5-second upper bound — fast when the invariant holds, bounded when it does not. - Trim the 6-line block comment on modded_request::response_ to a one-line cross-reference (finding 28: code-simplifier). The full DR-010 lifetime contract is documented canonically in webserver_impl.hpp and the architecture doc; repeating it in the struct adds noise. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 22d8f07 commit 5d49d42

2 files changed

Lines changed: 65 additions & 58 deletions

File tree

src/httpserver/detail/modded_request.hpp

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,8 @@ struct modded_request {
6363
httpserver::http_method method_enum = httpserver::http_method::count_;
6464

6565
std::unique_ptr<http_request> dhr = nullptr;
66-
// TASK-036 / DR-010 / §5.3: the response value lives here for the
67-
// full lifetime of the connection. The dispatch path moves the
68-
// handler's prvalue into this optional via emplace(); MHD's deferred
69-
// trampoline keeps a `cls` pointer into the deferred_body that lives
70-
// inside this http_response's SBO buffer. The optional is destroyed
71-
// by ~modded_request() in webserver_impl::request_completed, after
72-
// MHD has finished invoking the trampoline — so the captured
73-
// producer's state is released exactly once and exactly when DR-010
74-
// requires.
66+
// DR-010 / §5.3: anchor kept alive until request_completed.
67+
// See webserver_impl.hpp and specs/architecture for full contract.
7568
std::optional<http_response> response_;
7669
bool has_body = false;
7770
// TASK-047: set by a pre-handler hook short-circuit (request_received

test/integ/deferred.cpp

Lines changed: 63 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@
3737
#include <algorithm>
3838
#include <atomic>
3939
#include <chrono>
40+
#include <condition_variable>
4041
#include <cstdint>
4142
#include <cstdio>
4243
#include <utility>
4344
#include <cstring>
4445
#include <memory>
46+
#include <mutex>
4547
#include <string>
4648
#include <thread>
4749

@@ -259,28 +261,45 @@ LT_END_AUTO_TEST(deferred_response_empty_content)
259261
// ---------------------------------------------------------------------------
260262

261263
namespace {
262-
// Sentinel whose destructor flips an atomic; the destructor runs when the
263-
// http_response (and its deferred_body) is destroyed inside
264-
// ~modded_request() — which fires from webserver_impl::request_completed.
264+
// Sentinel whose destructor fires when the http_response (and its
265+
// deferred_body) is destroyed inside ~modded_request(), which fires from
266+
// webserver_impl::request_completed. On destruction it sets *destroyed to
267+
// true and notifies *cv so the test thread's timed_wait can return
268+
// immediately rather than spinning to a 1-second timeout.
265269
//
266-
// move_constructor leaves the source's pointer null so the moved-from
267-
// temporary's destructor is a no-op. Otherwise std::make_shared's
268-
// in-place copy/move of the temporary {&destroyed} aggregate would fire
269-
// the destructor on the source temporary at the end of the full
270-
// expression, flipping `destroyed` to true BEFORE the lambda even runs.
270+
// move_constructor leaves all pointers null so the moved-from temporary's
271+
// destructor is a no-op. Otherwise std::make_shared's in-place copy/move
272+
// of the temporary aggregate would flip `destroyed` BEFORE the lambda runs.
271273
struct destruction_sentinel {
272-
std::atomic<bool>* destroyed;
273-
explicit destruction_sentinel(std::atomic<bool>* d) : destroyed(d) {}
274+
std::atomic<bool>* destroyed;
275+
std::mutex* cv_mu;
276+
std::condition_variable* cv;
277+
278+
explicit destruction_sentinel(std::atomic<bool>* d,
279+
std::mutex* mu,
280+
std::condition_variable* c)
281+
: destroyed(d), cv_mu(mu), cv(c) {}
274282
destruction_sentinel(const destruction_sentinel&) = delete;
275283
destruction_sentinel& operator=(const destruction_sentinel&) = delete;
276284
destruction_sentinel(destruction_sentinel&& o) noexcept
277-
: destroyed(std::exchange(o.destroyed, nullptr)) {}
285+
: destroyed(std::exchange(o.destroyed, nullptr)),
286+
cv_mu(std::exchange(o.cv_mu, nullptr)),
287+
cv(std::exchange(o.cv, nullptr)) {}
278288
destruction_sentinel& operator=(destruction_sentinel&& o) noexcept {
279289
destroyed = std::exchange(o.destroyed, nullptr);
290+
cv_mu = std::exchange(o.cv_mu, nullptr);
291+
cv = std::exchange(o.cv, nullptr);
280292
return *this;
281293
}
282294
~destruction_sentinel() {
283-
if (destroyed) destroyed->store(true);
295+
if (!destroyed) return;
296+
destroyed->store(true);
297+
// Notify under the mutex so the waiting thread cannot miss the signal
298+
// if it is between loading destroyed and entering wait.
299+
{
300+
std::lock_guard<std::mutex> lk(*cv_mu);
301+
}
302+
cv->notify_one();
284303
}
285304
};
286305

@@ -346,34 +365,31 @@ LT_BEGIN_AUTO_TEST(deferred_suite, deferred_producer_destroyed_in_request_comple
346365
// capturing a shared_ptr<destruction_sentinel> inside the producer's
347366
// captures, then asserting the sentinel was destroyed after the server
348367
// is fully drained.
368+
//
369+
// Synchronization: sentinel destructor signals destroyed_cv so the test
370+
// thread wakes immediately rather than spinning. Upper bound is 5 s to
371+
// catch a genuine DR-010 regression without hanging the CI suite.
349372
std::atomic<bool> destroyed{false};
350-
std::atomic<int> producer_calls{0};
373+
std::atomic<int> producer_calls{0};
374+
std::mutex destroyed_mu;
375+
std::condition_variable destroyed_cv;
351376

352377
// CRITICAL: the OUTER on_get lambda is stored long-term inside the
353-
// registered lambda_resource. Anything it captures lives until the
354-
// webserver itself is destroyed. So the outer lambda captures
355-
// NOTHING but the atomic references — it allocates the
356-
// destruction_sentinel inline (via make_shared) inside the INNER
357-
// (producer) lambda's init-capture. The inner lambda's capture lives
358-
// inside the http_response (and its deferred_body) anchored on
359-
// mr->response_, so the sentinel is released exactly when
360-
// ~modded_request fires from request_completed (DR-010). shared_ptr
361-
// is required (not unique_ptr) because std::function — used as both
362-
// lambda_handler and deferred_body::producer_type — requires its
363-
// target to be CopyConstructible.
364-
ws->on_get("/lifetime", [&producer_calls, &destroyed](
365-
const http_request&) {
378+
// registered lambda_resource. Capture only lightweight references here;
379+
// the destruction_sentinel lives inside the INNER (producer) lambda so
380+
// it is released with the deferred_body when ~modded_request fires from
381+
// request_completed (DR-010). shared_ptr is required because
382+
// std::function requires its target to be CopyConstructible.
383+
ws->on_get("/lifetime",
384+
[&producer_calls, &destroyed, &destroyed_mu, &destroyed_cv](
385+
const http_request&) {
366386
return http_response::deferred(
367-
[sentinel = std::make_shared<destruction_sentinel>(&destroyed),
387+
[sentinel = std::make_shared<destruction_sentinel>(
388+
&destroyed, &destroyed_mu, &destroyed_cv),
368389
&producer_calls, &destroyed, served = 0](
369390
std::uint64_t, char* buf, std::size_t max) mutable -> ssize_t {
370-
// Defensive: the producer should never run after the
371-
// sentinel is destroyed; if it did, this read would be
372-
// undefined behaviour (we'd be reading a destroyed atomic
373-
// through the captured sentinel pointer). Assert via a
374-
// separate side-channel.
391+
// Defensive: producer must not run after sentinel is gone.
375392
if (destroyed.load()) {
376-
// Would be UB anyway, but flag the regression.
377393
return -1;
378394
}
379395
producer_calls.fetch_add(1);
@@ -397,9 +413,8 @@ LT_BEGIN_AUTO_TEST(deferred_suite, deferred_producer_destroyed_in_request_comple
397413
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writefunc);
398414
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
399415
// Force HTTP/1.0 (no keep-alive) so MHD closes the connection as
400-
// soon as curl finishes reading the body. That makes request_completed
401-
// fire deterministically before ws->stop() — without relying on the
402-
// keep-alive timeout.
416+
// soon as curl finishes reading the body, making request_completed
417+
// fire before ws->stop() in the common case.
403418
curl_easy_setopt(curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_0);
404419
CURLcode res = curl_easy_perform(curl);
405420
LT_ASSERT_EQ(res, 0);
@@ -413,20 +428,19 @@ LT_BEGIN_AUTO_TEST(deferred_suite, deferred_producer_destroyed_in_request_comple
413428
LT_CHECK(producer_calls.load() >= 2);
414429

415430
// Force MHD to fire request_completed for every pending connection.
416-
// MHD_stop_daemon (called by webserver::stop) joins the internal
417-
// threads and drains the request_completed queue. After stop()
418-
// returns, the modded_request (and its optional<http_response>
419-
// holding our deferred_body, holding the inner-lambda's
420-
// make_unique<destruction_sentinel>) MUST be gone. tear_down() will
421-
// call stop() again — idempotent on an already-stopped server (see
422-
// webserver::stop's running guard).
431+
// MHD_stop_daemon (called by stop()) joins internal threads and drains
432+
// the request_completed queue. tear_down() calls stop() again; that is
433+
// safe because webserver::stop() is idempotent once already stopped.
423434
ws->stop();
424-
// request_completed may run on a worker thread that completes a
425-
// hair after stop() returns to us. Poll briefly for the destruction
426-
// signal — bounded so a true regression (body anchor leaked past
427-
// request_completed) fails fast instead of hanging.
428-
for (int i = 0; i < 100 && !destroyed.load(); ++i) {
429-
std::this_thread::sleep_for(std::chrono::milliseconds(10));
435+
436+
// Wait for the sentinel to be destroyed. request_completed may fire on
437+
// a worker thread that finishes a hair after stop() returns, so we use
438+
// a condition variable rather than a busy-poll. 5-second upper bound
439+
// catches a genuine DR-010 regression without hanging CI indefinitely.
440+
{
441+
std::unique_lock<std::mutex> lk(destroyed_mu);
442+
destroyed_cv.wait_for(lk, std::chrono::seconds(5),
443+
[&destroyed] { return destroyed.load(); });
430444
}
431445
LT_CHECK_EQ(destroyed.load(), true);
432446
LT_END_AUTO_TEST(deferred_producer_destroyed_in_request_completed)

0 commit comments

Comments
 (0)