Skip to content

Commit 3be80df

Browse files
committed
FEATURE-ci-v3-runtime-green: tolerate borrowed-handle closure and enforce drain deadlines
1 parent b71192c commit 3be80df

6 files changed

Lines changed: 84 additions & 2 deletions

File tree

‎ChangeLog‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
Version 3.0.0 - 2026-10-10
2+
Keep the HTTP/1 connection watchdog armed at a pending server drain
3+
deadline so a stalled close handoff cannot outlive the drain budget.
4+
Tolerate a concurrently closed borrowed registration in the Windows
5+
reference host poll helper instead of failing the whole WSAPoll set.
26
Register each native executable once in Automake test evidence inventories.
37
Run Windows allocation-failure injection against the installed static
48
library and require ordinary readiness cases in both linkage lanes.

‎docs/external-loop-contract.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,15 @@ the resulting protocol/transport outcome. Duplicate or spurious notifications,
121121
including events with all flags false, are permitted and do not imply additional
122122
completions. Empty batches are permitted for timers and pending library work.
123123

124+
A registration borrowed by a snapshot can be closed by library work at any
125+
moment, because retaining a snapshot never extends a handle lifetime. A POSIX
126+
host observes this as a per-entry `POLLNVAL` readiness (translated to `error`
127+
and ignored as stale). Windows `WSAPoll` instead rejects the entire call with
128+
`WSAENOTSOCK`; a host must treat that failure like an empty batch — dispatch,
129+
acquire a fresh snapshot, and wait again — rather than stopping, because the
130+
closed registration is absent from every later snapshot. The reference
131+
`examples/host_poll.hpp` helper implements this tolerance.
132+
124133
Obtain an initial snapshot before waiting. After **every successful dispatch**,
125134
obtain and reconcile a fresh snapshot before waiting again. A failed dispatch
126135
may have consumed part of a batch unless the documented rejection rule says

‎examples/host_poll.hpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ inline std::vector<srv::readiness_event> wait(
102102
const auto ready = ::WSAPoll(fds.data(), static_cast<ULONG>(fds.size()), timeout(deadline));
103103
if (ready < 0) {
104104
const int error = ::WSAGetLastError();
105+
// Snapshot handles are borrowed: the library may close one between
106+
// snapshot acquisition and this call. POSIX poll reports that
107+
// per entry as POLLNVAL; WSAPoll rejects the whole set with
108+
// WSAENOTSOCK. An empty batch keeps the contract's spurious-
109+
// notification allowance: the caller dispatches, re-acquires
110+
// interests, and the closed registration is already gone.
111+
if (error == WSAENOTSOCK) return {};
105112
if (error != WSAEINTR) throw std::runtime_error("WSAPoll failed (WSA error " + std::to_string(error) + ")");
106113
}
107114
#else

‎specs/tasks/FEATURE-ci-v3-runtime-green.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,14 @@ The duplicated names are http_cookie, consumer_v3_cookie, consumer_v3_cookie_wir
128128
Evidence: primary `.debug/v3-ci-native-repairs-PJFEkn/native-inventory/`. Autoreconf succeeded with existing AC_OUTPUT/.PHONY warnings. Initial config.status invocation used the source directory instead of its configured build directory and failed before any native execution; rechecking/regenerating in the correct build directory fixed setup. No unrelated source or generated tracked file changed.
129129

130130
Current9c92 Windows package job122646921257 fails earlier at the public shared external consumer: exit6, captured stderr WSAPoll error10038. The selected820-byte process receipt was verified within a closed2,581,790-byte range assessment; no full artifact or original program ran. It contains no failed-call slot/state values. The earlier approved external4 capture remains CLOSED/PASS4/52/nonreproduction, and does not authorize running the distinct public consumer. No guessed polling or shutdown suppression was applied. A fresh consumer-specific bounded capture proposal is needed if further native diagnosis is required.
131+
132+
## Stage 7: Windows WSAPoll borrowed-closure tolerance and HTTP/1 drain-deadline watchdog (2026-10-30)
133+
134+
- [x] Correlate all retained Windows failures (external four-test suite twice, public shared consumer exit6) with the approved DIAG capture: the host loop dies on "WSAPoll failed (WSA error 10038)" while dispatch never failed and stopping was false.
135+
- [x] Root-cause by contract analysis, not a new capture: `interest_snapshot` handles are borrowed and "retaining a snapshot does not extend any handle lifetime"; the library closes registrations under its registry mutex and never republishes dead records, so WSAENOTSOCK can only mean a snapshot handle closed between snapshot and WSAPoll entry (handle-value reuse makes it WSAENOTSOCK on busy runners and POLLNVAL on idle probes, reconciling the closed local probes).
136+
- [x] Repair `examples/host_poll.hpp`: WSAENOTSOCK returns an empty batch (the contract's spurious-notification allowance); every reference caller re-acquires interests after each dispatch, and dead records are pruned from the next snapshot, so the loop provably progresses. All other WSAPoll failures still throw; POSIX POLLNVAL handling is unchanged. Document the host obligation in `docs/external-loop-contract.md`.
137+
- [x] Root-cause the 2026-10-28 macOS/Darwin external4 drain stall (idle keep-alive connection never closed after `begin_drain`; host silent for the full 10s budget; remaining 1): `watchdog_terminal_locked()` treated the quiesce close mark as terminal, but HTTP/1 enforces the server drain deadline only through the watchdog (`enforce_timeout` is the sole HTTP/1 `scope_.expire_if_due` caller), so a stalled close handoff had no deadline enforcement left.
138+
- [x] Repair: the close mark no longer retires the watchdog while a server drain deadline is pending; the deadline joins `plan_watchdog_locked` candidates through the existing `add_protocol_deadlines_locked` entry and fires through the unchanged global-expiry path. Deadline-less closes (request_close, response-Connection: close) stay terminal as before.
139+
- [x] RED/GREEN regression `quiesce_with_drain_deadline_keeps_watchdog_armed` (unstarted engine, deterministic plan sample; deadline-less case pins unchanged terminal behavior) plus behavioral `quiesce_with_drain_deadline_still_closes_promptly`. RED verified against unfixed source, GREEN against the repair, five consecutive clean runs.
140+
- [x] Linux focused gates: connection_engine 42/0 (x5), external_http1_e2e 4/0, websocket_drain 3/0, http1_websocket_upgrade 17/0, native_http1_e2e 62/0, external_readiness_adapter 28/0, examples/external_event_loop builds and serves; changed-file cpplint and `git diff --check` pass.
141+
- [ ] Full exact-source CI on the working branch (mingw64/macOS/FreeBSD lanes remain nonlocal and deferred to CI).

‎src/detail/connection_engine_websocket.cpp‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,13 @@ connection_engine::io_posture connection_engine::writer_posture(
227227
return io_posture::park;
228228
}
229229
bool connection_engine::watchdog_terminal_locked() const {
230-
return shutdown_ || phase_ == stream_phase::terminal || (close_after_drain_ && !websocket_);
230+
// The close mark alone must not retire the watchdog while a server
231+
// drain deadline is pending: HTTP/1 enforces that deadline's hard
232+
// stop only here (enforce_timeout's scope expiry), so a stalled
233+
// close handoff may not outlive the drain budget. The transport's
234+
// release still ends this loop with connection_closed.
235+
return shutdown_ || phase_ == stream_phase::terminal
236+
|| (close_after_drain_ && !websocket_ && !server_drain_deadline_);
231237
}
232238
bool connection_engine::transport_output_pending_locked() const {
233239
if (outbox_.queued_bytes() > 0) return true;

‎test/unit/connection_engine_test.cpp‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
#include <parity/response_frame.hpp>
6969

7070
#include "./io_loopback.hpp"
71+
#include "./websocket_engine_helpers.hpp"
7172
#include "./littletest.hpp"
7273

7374
namespace {
@@ -301,7 +302,7 @@ struct scenario {
301302
explicit scenario(srv::timeout_options timeouts_in)
302303
: scenario(srv::budget_limits(), timeouts_in) { }
303304

304-
bool start_engine() {
305+
bool create_engine() {
305306
if (!pair.ok()) return false;
306307
backend.adopt_connection(kConnId, pair.detach_local());
307308
engine = std::make_shared<connection_engine>(backend, pool, registry,
@@ -310,6 +311,11 @@ struct scenario {
310311
[this] {
311312
stopped.store(true);
312313
});
314+
return true;
315+
}
316+
317+
bool start_engine() {
318+
if (!create_engine()) return false;
313319
engine->start();
314320
return true;
315321
}
@@ -1215,6 +1221,45 @@ LT_BEGIN_AUTO_TEST(connection_engine_suite, quiesce_idle_connection_closes_promp
12151221
LT_CHECK(reaches_eof(s.pair.peer()));
12161222
LT_END_AUTO_TEST(quiesce_idle_connection_closes_promptly)
12171223

1224+
// TASK-110 pin: a quiesce that carries a server drain deadline keeps the
1225+
// watchdog armed at that deadline. HTTP/1 enforces the drain budget's
1226+
// hard stop only through the watchdog (enforce_timeout's scope expiry),
1227+
// so the close mark alone must not retire it while the deadline is
1228+
// pending -- a stalled close handoff may not outlive the drain budget.
1229+
// Loops stay unstarted so the plan is sampled before any close path can
1230+
// complete; a deadline-less quiesce stays terminal exactly as before.
1231+
LT_BEGIN_AUTO_TEST(connection_engine_suite, quiesce_with_drain_deadline_keeps_watchdog_armed)
1232+
srv::timeout_options timeouts;
1233+
timeouts.header = std::chrono::milliseconds(10000);
1234+
scenario s(timeouts);
1235+
LT_CHECK(s.create_engine());
1236+
const auto drain_deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2);
1237+
s.engine->quiesce(drain_deadline);
1238+
const auto armed = httpserver::detail::connection_engine_test_access::deadline(*s.engine);
1239+
LT_CHECK(armed.has_value());
1240+
if (armed) LT_CHECK(*armed <= drain_deadline);
1241+
scenario plain;
1242+
LT_CHECK(plain.create_engine());
1243+
plain.engine->quiesce();
1244+
LT_CHECK(!httpserver::detail::connection_engine_test_access::deadline(*plain.engine).has_value());
1245+
LT_END_AUTO_TEST(quiesce_with_drain_deadline_keeps_watchdog_armed)
1246+
1247+
// The armed drain deadline must not delay the ordinary close chain: a
1248+
// started engine quiesced with a far drain budget still ends (and the
1249+
// transport closes) promptly, as in the deadline-less case above.
1250+
LT_BEGIN_AUTO_TEST(connection_engine_suite, quiesce_with_drain_deadline_still_closes_promptly)
1251+
srv::timeout_options timeouts;
1252+
timeouts.header = std::chrono::milliseconds(10000);
1253+
scenario s(timeouts);
1254+
LT_CHECK(s.start_engine());
1255+
const auto began = std::chrono::steady_clock::now();
1256+
s.engine->quiesce(std::chrono::steady_clock::now() + std::chrono::seconds(10));
1257+
LT_CHECK(wait_until([&s] { return s.stopped.load(); }));
1258+
const auto elapsed = std::chrono::steady_clock::now() - began;
1259+
LT_CHECK(elapsed < std::chrono::milliseconds(5000));
1260+
LT_CHECK(reaches_eof(s.pair.peer()));
1261+
LT_END_AUTO_TEST(quiesce_with_drain_deadline_still_closes_promptly)
1262+
12181263
// TASK-110: the counted units bracket the exchange -- the handler runs
12191264
// between the connection unit and its own exchange unit (2 live), and
12201265
// the count returns to zero once the engine finalized.

0 commit comments

Comments
 (0)