Skip to content

Commit 1c7e490

Browse files
committed
Merge FEATURE-ci-outbox-capacity: Admit allocator-rounded response storage
2 parents 56da7ec + ec13752 commit 1c7e490

4 files changed

Lines changed: 318 additions & 22 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# FEATURE-ci-outbox-capacity: portable HTTP/1 outbox capacity admission
2+
3+
Status: Complete (local implementation, Groundwork validation and finalization). Native execution remains CI / v3 PR owned. Base: `v3` at `56da7ec9e7ca848de9a19e58fbeb59e2baaa801c`.
4+
5+
A valid response head previously failed under libc++19 because `reserve(276)` retained capacity279 and the outbox required an exact capacity match. The unchanged real outbox reproduced the failure while its framer accepted the same response. Native macOS CI attribution remains corroborated; the original failing wire/subcase was not observed.
6+
7+
The outbox now admits the existing geometric request and then any observed allocator-rounded excess before copying or publishing. It records actual retained capacity, keeps the old buffer charged until destruction, and rolls back provisional storage on refusal or exception. Final-head exceptions restore the previous interim buffer and its charge. Growth arithmetic checks string/aggregate bounds. This preserves the existing transient private candidate allocation behavior; it does not promise advance knowledge of allocator rounding.
8+
9+
Action items:
10+
11+
- [x] Isolate from v3; preserve unrelated worktrees.
12+
- [x] Reproduce original real libc++ outbox refusal with a durable behavior regression (RED).
13+
- [x] Admit actual rounded capacity without changing budget limits or serialized ownership.
14+
- [x] Verify exact root/child charge, child/parent refusal, old-plus-replacement admission, byte preservation, rollback and release.
15+
- [x] Verify exception/overflow negative contracts against actual outbox paths.
16+
- [x] Apply doubt-driven development before commit; evidence: `.debug/FEATURE-ci-outbox-capacity/doubts.md`.
17+
- [x] Finish focused GNU/libc++ outbox and affected existing resource-allocation checks, plus cpplint.
18+
19+
Final verification: GNU and libc++19 each pass the full affected outbox executable (29 tests/244 checks), four unchanged selected existing H1 resource-allocation tests (4 tests/414 checks), and actual allocation-failure/SIZE_MAX probes. cpplint passes all three changed C++ files; `git diff --check` passes. The focused resource selection is not a claim that the complete cross-protocol resource suite ran. Source hashes and original failing/passing commands and outputs are retained with the journal.
20+
21+
Evidence is retained in `.debug/FEATURE-ci-outbox-capacity/`: commands, build/run logs, original RED, bounded fault probe and journal. Scratch probe programs remain uncommitted. No broad baseline suite was run. Native macOS/BSD/Windows execution remains CI/v3-PR owned under AGENTS.md and does not block local completion.
22+
23+
Separate Linux incidental failure remains **UNCERTAIN**, as settled by the prior single bounded check. Its evidence is `.debug/FEATURE-ci-native-runtime/journal.md`: native `icy_http10_and_head_keep_framing` assertions354/356 failed; a subsequent unchanged-core parity replay passed6 tests/35 assertions but its process failed LSan because ptrace is unsupported in the sandbox. This is not a passing process gate or proof of independence. This task does not reopen, retry or repair that failure.
24+
25+
Project notes: C++20; normal setup is `./bootstrap`, out-of-tree `configure --disable-v3-tls`, `make`. Tests use `test/littletest.hpp`; this task directly compiled the affected existing test with its real production translation units using GNU libstdc++ and the retained task-local libc++19, without installing dependencies. `cpplint` reads root `CPPLINT.cfg` (200-column limit). Worktree creation required the existing Git sandbox escalation; branch/path came verbatim from Groundwork's identity helper. No configured agent memory was available.
26+
27+
- [x] Caller: Groundwork validation completed with seven approvals and zero findings; matching evidence reused.
28+
- [x] Caller: local task finalization into v3.
29+
- [ ] CI / v3 PR: obtain fresh native platform evidence; no release-readiness claim.

‎src/detail/http1_response_outbox.cpp‎

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/* Copyright (C) 2026 Sebastiano Merlino; SPDX-License-Identifier: LGPL-2.1-or-later */
22
#include <algorithm>
3+
#include <limits>
34
#include <memory>
45
#include <string>
56
#include <utility>
@@ -142,19 +143,30 @@ auto http1_response_outbox::reserve_buffer_locked(http1_response_sink& sink, std
142143
sink.storage_units_ = sink.bytes_.capacity();
143144
return true;
144145
}
145-
const auto bound = std::max(size, 2 * sink.bytes_.capacity());
146-
if (!storage_charge_.grow(bound)) return false;
146+
const auto maximum = sink.bytes_.max_size();
147+
if (size > maximum) return false;
148+
const auto capacity = sink.bytes_.capacity();
149+
const auto bound = std::max(size, capacity > maximum / 2 ? maximum : 2 * capacity);
150+
if (bound > std::numeric_limits<std::size_t>::max() - storage_charge_.units() ||
151+
!storage_charge_.grow(bound)) return false;
152+
// Destroy the private candidate before rolling back its admission.
153+
struct provisional_charge {
154+
resource_charge& charge;
155+
std::size_t units;
156+
~provisional_charge() { charge.shrink(units); }
157+
} pending{storage_charge_, bound};
147158
std::string replacement;
148159
replacement.reserve(bound);
149-
if (replacement.capacity() > bound) {
150-
std::string().swap(replacement);
151-
storage_charge_.shrink(bound);
152-
return false;
153-
}
160+
const auto actual = replacement.capacity();
161+
const auto excess = actual - bound;
162+
if (excess > std::numeric_limits<std::size_t>::max() - storage_charge_.units() ||
163+
!storage_charge_.grow(excess)) return false;
164+
pending.units = actual;
154165
replacement.append(sink.bytes_);
155166
sink.bytes_.swap(replacement);
156167
std::string().swap(replacement);
157-
storage_charge_.shrink(std::exchange(sink.storage_units_, bound));
168+
storage_charge_.shrink(std::exchange(sink.storage_units_, actual));
169+
pending.units = 0;
158170
return true;
159171
}
160172

@@ -319,23 +331,33 @@ http::outcome http1_response_sink::start(const http::request_head& request, cons
319331
// candidate allocation before returning any of its shared capacity.
320332
std::string previous = std::move(bytes_);
321333
const auto previous_units = std::exchange(storage_units_, 0);
322-
const auto rollback = [this, &previous, previous_units](http::outcome failure) {
334+
bool restored = false;
335+
const auto restore = [this, &previous, previous_units, &restored] {
336+
if (std::exchange(restored, true)) return;
323337
owner_->release_buffer_locked(*this);
324338
bytes_ = std::move(previous);
325339
storage_units_ = previous_units;
340+
};
341+
const auto rollback = [this, &restore](http::outcome failure) {
342+
restore();
326343
return fail_locked(failure);
327344
};
328-
std::size_t bound = 0;
329-
if (!owner_->field_bound(f, 256, bound) || !owner_->reserve_buffer_locked(*this, before_head + bound)) {
330-
return rollback({http::outcome_code::limit_exceeded, "shared response head capacity exhausted"});
331-
}
332-
bytes_.append(previous);
333-
const http::outcome head = framer_->start_head(bytes_, request, s, f);
334-
if (!head.ok()) return rollback(head);
335-
if (bytes_.size() > owner_->budget_.max_head_bytes) {
336-
return rollback(http::outcome(http::outcome_code::limit_exceeded,
337-
"http1_response_sink: serialized response head exceeds the"
338-
" head budget"));
345+
try {
346+
std::size_t bound = 0;
347+
if (!owner_->field_bound(f, 256, bound) || !owner_->reserve_buffer_locked(*this, before_head + bound)) {
348+
return rollback({http::outcome_code::limit_exceeded, "shared response head capacity exhausted"});
349+
}
350+
bytes_.append(previous);
351+
const http::outcome head = framer_->start_head(bytes_, request, s, f);
352+
if (!head.ok()) return rollback(head);
353+
if (bytes_.size() > owner_->budget_.max_head_bytes) {
354+
return rollback(http::outcome(http::outcome_code::limit_exceeded,
355+
"http1_response_sink: serialized response head exceeds the"
356+
" head budget"));
357+
}
358+
} catch (...) {
359+
restore();
360+
throw;
339361
}
340362
std::string().swap(previous);
341363
owner_->storage_charge_.shrink(previous_units);

‎src/httpserver/detail/http1_response_outbox.hpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,9 @@ class http1_response_outbox {
243243

244244
// Preallocate the whole candidate before serialization and keep the
245245
// old allocation charged until its replacement has copied the bytes.
246-
// Geometric growth preserves amortized append cost. An allocator that
247-
// exceeds this admitted bound is refused before publishing its buffer.
246+
// Geometric growth preserves amortized append cost. Allocator-rounded
247+
// excess must also be admitted before copying or publishing the buffer;
248+
// refusal or an exception destroys the candidate and rolls back its charge.
248249
// Inline capacity is conservatively included for nonempty buffers.
249250
bool reserve_buffer_locked(http1_response_sink& sink, std::size_t size);
250251

0 commit comments

Comments
 (0)