Skip to content

Commit 34c3543

Browse files
committed
Merge TASK-188: Publish immutable peer policies without writer starvation
2 parents 4de49a3 + 1b5c52f commit 34c3543

10 files changed

Lines changed: 597 additions & 88 deletions

File tree

‎specs/architecture/v3/TASK-188-implementation-evidence.md‎

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

‎specs/architecture/v3/architecture.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ route/admission/lifecycle services + semantic HTTP stream
5151

5252
Each connection has one serialized protocol-state owner; request handlers may run concurrently on configured workers. State-changing operations from handlers return to the connection owner. Shared route tables, IP policy, hooks, and TLS snapshots publish immutable generations so readers need no global hot-path lock. This is a target responsibility split, not a direct transplant of v2's MHD callback services.
5353

54+
Peer policy implements this split with an atomic `shared_ptr<const generation>` owning both pattern vectors, enabled state, mode and derived armed state. A writer mutex serializes read-modify-publish operations against the latest generation, preventing lost independent updates. Parsing happens before serialization. Each reader retains one owned generation through both scans; no reader-held shared mutex spans the scan. Atomic shared-pointer operations are not promised to be lock-free. The scalar `armed()` acquire load remains the unused-policy gate. Writers release-publish the generation before release-storing that gate; after passing the gate, classification acquire-loads a generation and checks its own armed state, including disabled acceptance. Overlapping calls may use an older complete generation; operations synchronized after a completed mutation observe the published update.
55+
56+
Candidate construction leaves the previous generation intact on failure. Pattern mutators retain their existing throwing allocation behavior and typed invalid-pattern outcomes. The existing `noexcept` mode/enabled setters now allocate to copy a generation: allocation failure terminates, rather than silently dropping an update. These setters already offered no recoverable failure channel for synchronization failures. Generation lifetime is bounded by the current publication and in-flight readers; the policy introduces no reclamation queue or custom hazard-pointer scheme. TASK-188's bounded progress, independent-writer and installed listener evidence pins this publication contract.
57+
5458
### 3.1 Public semantic interface (illustrative, not final signatures)
5559

5660
```cpp

‎specs/tasks/M14-v3-release-repairs/TASK-188.md‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@
99
**Workflow:** Follow `../v3-release-repair-workflow.md`; resolve TASK-188 with the worktree helper, require `base_branch=v3`, root-cause investigation and verified RED/GREEN before changes, then Groundwork validation/finalization.
1010

1111
**Action Items:**
12-
- [ ] Reproduce the frozen `peer_policy/concurrent_mutation_and_reads` stall. Inspect test joining/termination and `src/detail/peer_policy.cpp` shared-mutex paths; collect bounded writer-progress observations before concluding starvation, deadlock or test lifecycle error.
13-
- [ ] Add a bounded concurrency regression which holds reader pressure until a writer completes, asserts consistent classifications and fails on missing mutation progress. Terminate/join every thread on failure; do not solve this by weakening the number of mutations/readers, adding sleeps, raising the release deadline or making the readers stop before writers need progress.
14-
- [ ] Apply the proven repair. Prefer architecture's immutable generation publication with serialized writers if shared-mutex starvation is established; preserve atomic stance/list consistency, deny/allow precedence, typed invalid patterns, armed fast path and live mutation from handlers. Clear concurrency doubts with tests before committing.
15-
- [ ] Check concurrent add/remove/mode/enabled publication and real public listener live policy changes; retain parser/truth-table compatibility and update architecture if the implementation publication model changes.
12+
- [x] Reproduce the frozen `peer_policy/concurrent_mutation_and_reads` stall. Inspect test joining/termination and `src/detail/peer_policy.cpp` shared-mutex paths; collect bounded writer-progress observations before concluding starvation, deadlock or test lifecycle error.
13+
- [x] Add a bounded concurrency regression which holds reader pressure until a writer completes, asserts consistent classifications and fails on missing mutation progress. Terminate/join every thread on failure; do not solve this by weakening the number of mutations/readers, adding sleeps, raising the release deadline or making the readers stop before writers need progress.
14+
- [x] Apply the proven repair. Prefer architecture's immutable generation publication with serialized writers if shared-mutex starvation is established; preserve atomic stance/list consistency, deny/allow precedence, typed invalid patterns, armed fast path and live mutation from handlers. Clear concurrency doubts with tests before committing.
15+
- [x] Check concurrent add/remove/mode/enabled publication and real public listener live policy changes; retain parser/truth-table compatibility and update architecture if the implementation publication model changes.
1616

1717
**Acceptance Criteria:**
1818
- Existing `peer_policy` finishes within the unchanged 180-second release deadline under normal TLS-off/on; the bounded writer-progress regression passes under normal, ASan/UBSan and TSan.
@@ -24,4 +24,6 @@
2424
- Blocks: TASK-189, TASK-185
2525
**Related Requirements:** PRD-V3N-REQ-014, PRD-V3N-REQ-025, PRD-V3N-REQ-038.
2626
**Related Decisions:** DR-V3-001, DR-V3-006, DR-V3-008; architecture §3 immutable policy generations.
27-
**Status:** Not Started
27+
**Status:** Complete
28+
29+
**Implementation evidence:** [TASK-188 implementation evidence](../../architecture/v3/TASK-188-implementation-evidence.md). Six local C++20 normal/ASan/UBSan/TSan TLS-off/on lanes and installed managed/external live-policy consumers pass; Groundwork validation passed in one iteration with eight approvals, zero fixes and zero unworked findings (run `476f15d40115b153d901c612b1b6473d`). All implementation build/debug receipts are retained beneath `build/task188-evidence/implementation-2cc58b4b/` in the primary worktree, with a SHA256 archive manifest.

‎specs/tasks/_index.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,7 @@ Nominally: **13 sequential tasks**, each S–XL. Most other tasks parallelize of
335335
| TASK-185 | [Run final dependency, conformance, sanitizer and performance release gates](M13-v3-release/TASK-185.md) | M13 | In Progress | TASK-180, TASK-181, TASK-182, TASK-183, TASK-184, TASK-200 |
336336
| TASK-186 | [Repair concurrency consumer lifetime and sanitizer-owned allocation coverage](M14-v3-release-repairs/TASK-186.md) | M14 | Complete | None |
337337
| TASK-187 | [Repair response-source and multipart coroutine lifetimes](M14-v3-release-repairs/TASK-187.md) | M14 | Complete | TASK-186 |
338-
| TASK-188 | [Eliminate peer-policy mutation starvation](M14-v3-release-repairs/TASK-188.md) | M14 | Not Started | TASK-187 |
338+
| TASK-188 | [Eliminate peer-policy mutation starvation](M14-v3-release-repairs/TASK-188.md) | M14 | Complete | TASK-187 |
339339
| TASK-189 | [Repair native HTTP/1 WebSocket handshake and flood progress](M14-v3-release-repairs/TASK-189.md) | M14 | Not Started | TASK-188 |
340340
| TASK-190 | [Repair HTTP/2 conformance route-effect ordering](M14-v3-release-repairs/TASK-190.md) | M14 | Not Started | TASK-189 |
341341
| TASK-191 | [Repair independent HTTP/3 semantic drain completion](M14-v3-release-repairs/TASK-191.md) | M14 | Not Started | TASK-190 |

‎src/detail/peer_policy.cpp‎

Lines changed: 48 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,13 @@
1919
02110-1301 USA
2020
*/
2121

22-
// TASK-119 step 2: the peer policy storage (server/peer_policy.hpp).
23-
// Two pattern lists under ONE shared mutex (a classify() snapshot is
24-
// consistent across both; v2 used one mutex per list and could observe
25-
// a deny from before a mutation and an allow from after it). The
26-
// prefix-order insert keeps each list free of covering pairs, so
27-
// membership is a plain scan. armed() is an atomic recomputed under
28-
// the same lock at every mutation, keeping the zero-cost gate
29-
// consistent with the store it summarizes.
22+
// Readers retain one immutable generation while scanning. Writers serialize
23+
// read-modify-publish operations; no reader-held lock spans a pattern scan.
3024

3125
#include <atomic>
3226
#include <memory>
3327
#include <mutex>
3428
#include <optional>
35-
#include <shared_mutex>
3629
#include <string>
3730
#include <utility>
3831
#include <vector>
@@ -103,9 +96,10 @@ class peer_policy_impl {
10396
" spelling");
10497
}
10598
{
106-
std::unique_lock<std::shared_mutex> lock(mu_);
107-
insert_aware(denying ? deny_ : allow_, *parsed);
108-
recompute_armed_locked();
99+
std::lock_guard<std::mutex> lock(writers_);
100+
auto next = std::make_shared<generation>(*current_.load(std::memory_order_acquire));
101+
insert_aware(denying ? next->deny : next->allow, *parsed);
102+
publish(std::move(next));
109103
}
110104
return http::outcome::okay();
111105
}
@@ -121,78 +115,90 @@ class peer_policy_impl {
121115
" spelling");
122116
}
123117
{
124-
std::unique_lock<std::shared_mutex> lock(mu_);
118+
std::lock_guard<std::mutex> lock(writers_);
119+
auto next = std::make_shared<generation>(*current_.load(std::memory_order_acquire));
125120
std::vector<net::address_pattern>& list =
126-
denying ? deny_ : allow_;
121+
denying ? next->deny : next->allow;
127122
for (std::size_t i = 0; i < list.size(); ++i) {
128123
if (list[i] == *parsed) {
129124
list.erase(list.begin()
130125
+ static_cast<std::ptrdiff_t>(i));
131126
break;
132127
}
133128
}
134-
recompute_armed_locked();
129+
publish(std::move(next));
135130
}
136131
return http::outcome::okay();
137132
}
138133

139134
void set_mode(peer_policy_mode mode) noexcept {
140-
std::unique_lock<std::shared_mutex> lock(mu_);
141-
mode_ = mode;
142-
recompute_armed_locked();
135+
std::lock_guard<std::mutex> lock(writers_);
136+
auto next = std::make_shared<generation>(*current_.load(std::memory_order_acquire));
137+
next->mode = mode;
138+
publish(std::move(next));
143139
}
144140

145141
peer_policy_mode mode() const noexcept {
146-
std::shared_lock<std::shared_mutex> lock(mu_);
147-
return mode_;
142+
return current_.load(std::memory_order_acquire)->mode;
148143
}
149144

150145
void set_enabled(bool enabled) noexcept {
151-
std::unique_lock<std::shared_mutex> lock(mu_);
152-
enabled_ = enabled;
153-
recompute_armed_locked();
146+
std::lock_guard<std::mutex> lock(writers_);
147+
auto next = std::make_shared<generation>(*current_.load(std::memory_order_acquire));
148+
next->enabled = enabled;
149+
publish(std::move(next));
154150
}
155151

156152
bool enabled() const noexcept {
157-
std::shared_lock<std::shared_mutex> lock(mu_);
158-
return enabled_;
153+
return current_.load(std::memory_order_acquire)->enabled;
159154
}
160155

161156
bool armed() const noexcept {
162157
return armed_.load(std::memory_order_acquire);
163158
}
164159

165-
// The consistent snapshot decision: both lists under one shared
166-
// lock, then the v2 truth table.
167160
peer_verdict classify(const net::peer_address& peer) {
168161
if (!armed()) return peer_verdict{};
169-
std::shared_lock<std::shared_mutex> lock(mu_);
170-
const bool denied = matches_locked(deny_, peer.address);
171-
const bool allowed = matches_locked(allow_, peer.address);
172-
return decide(mode_, denied, allowed);
162+
const auto snapshot = current_.load(std::memory_order_acquire);
163+
// A concurrent disarm may have published after the gate was observed.
164+
if (!snapshot->armed) return peer_verdict{};
165+
const bool denied = matches(snapshot->deny, peer.address);
166+
const bool allowed = matches(snapshot->allow, peer.address);
167+
return decide(snapshot->mode, denied, allowed);
173168
}
174169

175170
private:
176-
static bool matches_locked(const std::vector<net::address_pattern>& list,
177-
const net::address& candidate) noexcept {
171+
static bool matches(const std::vector<net::address_pattern>& list,
172+
const net::address& candidate) noexcept {
178173
for (const net::address_pattern& entry : list) {
179174
if (entry.matches(candidate)) return true;
180175
}
181176
return false;
182177
}
183178

184-
void recompute_armed_locked() noexcept {
185-
const bool armed = enabled_
186-
&& (mode_ == peer_policy_mode::reject_all || !deny_.empty()
187-
|| !allow_.empty());
179+
struct generation {
180+
std::vector<net::address_pattern> deny;
181+
std::vector<net::address_pattern> allow;
182+
peer_policy_mode mode = peer_policy_mode::accept_all;
183+
bool enabled = true;
184+
bool armed = false;
185+
};
186+
187+
void publish(std::shared_ptr<generation> next) noexcept {
188+
next->armed = next->enabled
189+
&& (next->mode == peer_policy_mode::reject_all || !next->deny.empty()
190+
|| !next->allow.empty());
191+
const bool armed = next->armed;
192+
// Publish ownership before releasing the scalar gate. Readers arriving
193+
// during publication may use an older complete generation; readers
194+
// synchronized after a mutation see its gate and generation together.
195+
current_.store(std::move(next), std::memory_order_release);
188196
armed_.store(armed, std::memory_order_release);
189197
}
190198

191-
mutable std::shared_mutex mu_;
192-
std::vector<net::address_pattern> deny_;
193-
std::vector<net::address_pattern> allow_;
194-
peer_policy_mode mode_ = peer_policy_mode::accept_all;
195-
bool enabled_ = true;
199+
std::mutex writers_;
200+
std::atomic<std::shared_ptr<const generation>> current_{
201+
std::make_shared<const generation>()};
196202
std::atomic<bool> armed_{false};
197203
};
198204

‎src/httpserver/server/peer_policy.hpp‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
// TASK-119 (plan D5): the v3 peer policy store -- the v3 equivalent
2323
// of v2's deny_ip/allow_ip sets with default_policy (PRD-V3N-REQ-014,
2424
// DR-V3-008). One server-wide policy: two pattern lists (deny, allow)
25-
// under one lock, a mode (the v2 default_policy), and an enabled
26-
// flag. Runtime-mutable from any thread including inside handlers:
27-
// every member is synchronized, and classify() takes a consistent
28-
// snapshot of both lists.
25+
// in an immutable generation with a mode (the v2 default_policy) and
26+
// an enabled flag. Runtime-mutable from any thread including handlers:
27+
// writers serialize publication; classify() retains one complete generation
28+
// without holding a shared mutex across the scan.
2929
//
3030
// Pattern vocabulary: net::parse_pattern (exact literals, trailing-*
3131
// IPv4 wildcards, CIDR over both families). Pattern spellings that
@@ -53,7 +53,7 @@
5353
// (not_on_allow_list) and accept_all admits it.
5454
//
5555
// Zero-cost-when-unused: armed() is one atomic load, false when the
56-
// policy is disabled, the mode is accept_all, and both lists are
56+
// policy is disabled OR the mode is accept_all and both lists are
5757
// empty; an engine that observes !armed() may skip classify()
5858
// entirely (the observable verdict is the same).
5959
//

‎test/Makefile.am‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,3 +1745,9 @@ endif
17451745

17461746
# Native fixture dependencies must accompany their translation units in distributions.
17471747
noinst_HEADERS += integ/raw_http_client.hpp parity/case_wire.hpp parity/v3_dispatch_replay.hpp unit/response_source_rig.hpp
1748+
1749+
check_PROGRAMS += consumer_v3_peer_policy_runtime
1750+
consumer_v3_peer_policy_runtime_SOURCES = integ/consumer_v3_peer_policy_runtime.cpp
1751+
consumer_v3_peer_policy_runtime_CPPFLAGS = -I$(top_srcdir)/src -I$(top_srcdir)/examples
1752+
consumer_v3_peer_policy_runtime_LDADD = $(top_builddir)/src/libhttpserver_v3core.la
1753+
consumer_v3_peer_policy_runtime_LDFLAGS = $(V3_TLS_LDFLAGS) $(AM_LDFLAGS)

0 commit comments

Comments
 (0)