Give Completion<T> a value-handling contract, and stop a benchmark racing on a dead frame - #579
Merged
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Yaraslaut
force-pushed
the
fix/553-565-completion-value-contract
branch
from
September 19, 2026 07:06
78a09d9 to
a7b0749
Compare
… deadline
The throughput benchmark in test_server_limits.cpp held `done` as a stack
local of the BENCHMARK body and captured it by reference into reply
callbacks that run on ThreadPoolExecutor workers. The 1 s deadline lets
the wait loop exit with replies still in flight, so the body returns,
`done` is destroyed, and a straggler `fetch_add`s into a dead frame.
Catch2 re-enters the body for the next sample and constructs a fresh
counter over the same stack slot -- that construction is what races.
Reproduced under TSan before the change, benchmarks explicitly enabled:
WARNING: ThreadSanitizer: data race (pid=496638)
Write of size 4 at 0x7ffc616f336c by main thread:
#1 std::atomic<int>::atomic(int)
#2 CATCH2_INTERNAL_TEST_11()::$_0::operator()(int) const
tests/test_server_limits.cpp:192:26
Previous atomic write of size 4 at 0x7ffc616f336c by thread T4:
#0 std::__atomic_base<int>::fetch_add(int, std::memory_order)
#1 ...::operator()(std::string const&) const
tests/test_server_limits.cpp:196:63
Location is stack of main thread.
ThreadSanitizer: reported 2 warnings
The counter becomes a `shared_ptr<std::atomic<int>>` co-owned by every
callback, so a late reply writes into a live object. The deadline stays,
demoted to what it always was in practice -- a benchmark timeout that
keeps a wedged server from becoming a hang -- rather than a correctness
device that lengthening could have fixed.
After, same command: zero warnings, and the benchmark still reports a
number (6.13 ms mean over 100 samples across the whole [limits] set,
970 assertions in 16 test cases, no TSan output).
Closes #565
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
…e copyable
`morph::async::Completion<T>` did not state what it does with `T`. Measured
with an instrumented `T` counting copy and move constructions, N handlers
attached before settling and M after, the budget was N + 2M copies per
settle -- regardless of handler signature -- and `T` had to be
copy-constructible.
Before, against this header (g++ 16.2.1 -O2, Linux x86-64):
handler signature: const Probe& handler signature: Probe (by value)
before=0 after=0 -> copies=0 before=0 after=0 -> copies=0
before=1 after=0 -> copies=1 before=1 after=0 -> copies=1
before=3 after=0 -> copies=3 before=3 after=0 -> copies=3
before=0 after=1 -> copies=2 before=0 after=1 -> copies=2
before=0 after=2 -> copies=4 before=0 after=2 -> copies=4
before=1 after=1 -> copies=3 before=1 after=1 -> copies=3
before=3 after=3 -> copies=9 before=3 after=3 -> copies=9
After, same instrument, same compiler:
handler signature: const Probe& handler signature: Probe (by value)
before=0 after=0 -> copies=0 before=0 after=0 -> copies=0
before=1 after=0 -> copies=0 before=1 after=0 -> copies=1
before=3 after=0 -> copies=0 before=3 after=0 -> copies=3
before=0 after=1 -> copies=0 before=0 after=1 -> copies=1
before=0 after=2 -> copies=0 before=0 after=2 -> copies=2
before=1 after=1 -> copies=0 before=1 after=1 -> copies=2
before=3 after=3 -> copies=0 before=3 after=3 -> copies=6
Zero copies for a `const T&` handler; exactly one for a by-value handler,
charged at that handler's own parameter binding. Neither number depends on
handler count or on which side of the settle the handler attached. Moves
during a settle drop to a constant 2 (into `setValue`'s by-value parameter,
then into `value`) from as many as 17.
Two changes do it. `onOk` is erased as
`std::vector<std::function<void(const T&)>>` rather than `void(T)`: one
erased type accepts `[](const T&)`, `[](T)`, `[](auto)` and an existing
`std::function<void(T)>` object alike, because each is invocable with
`const T&`, so a handler pays for a copy only if it asks for one. And
`CompletionState<T>` derives from `std::enable_shared_from_this`, so both
dispatch closures capture the state and read `*self->value` in place instead
of carrying a snapshot. That also removes the plain defect the issue names:
`attachThen`'s fire-now path copied `*value` into `savedVal` and then
captured `savedVal` *by copy* before moving it into the handler -- two
copies where the handler wanted at most one.
`T` now need only be `std::move_constructible`, asserted on the state with a
message naming the per-handler rule. `Completion<std::unique_ptr<int>>`
compiles and fans out, including through the `CallbackScope`-gated overloads;
on master it failed at four sites with `use of deleted function
'std::unique_ptr::unique_ptr(const std::unique_ptr&)'`. `IExecutor::post` is
untouched, because the dispatch closure captures a `shared_ptr` rather than
the value and so stays copy-constructible.
Reading `value` from the dispatch closures without `mtx` is safe because
`value` is write-once: `setValue` and `setException` both return early when
`ready`, nothing else assigns it, the store happens under the lock before the
closure reaches the executor, and the executor's queue supplies the
happens-before edge. Full suite green under TSan and under ASan+UBSan.
Public signature change: `then()`, `thenDetached()` and the gated overloads
take `std::function<void(const T&)>`. All 312 `.then(` call sites in the tree
compile unchanged -- verified by building the tests, the examples and every
ladder rung (2636 ladder tests pass). A handler taking `T&` or `T&&` would
not convert; none exists.
Tests: `tests/test_completion_value_contract.cpp` pins the budget **exactly**
(not "at most" -- an upper bound absorbs the regression it exists to catch)
for 0/1/2/3 handlers before, after and mixed, for all three handler
spellings, plus move-only `T` and the gated path. Observed failing first: its
five runnable cases fail against master's header (23 of 61 assertions, e.g.
`CHECK( g_counts.copies == 2 )` expanding to `7 == 2`), and its two
move-only cases do not compile there at all.
`test_completion_multi_handler.cpp`'s "a throwing T copy leaves the state
unsettled" test pinned the old shape; a throwing copy constructor is now
unreachable on the value path. It is replaced by two tests: one settling an
*armed* `ThrowOnCopy` through `const T&` handlers and requiring it not to
throw -- which is what makes "zero copies" a trap rather than a comment --
and one on a new `ThrowOnMove` pinning the strong exception guarantee that
`setValue` actually still has, against a throwing move constructor.
docs/spec/core/completion.md gains a "Value-handling contract" section, and
its summary-table row claiming `setValue` "moves it only into the last
handler's invocation" -- now false -- is replaced.
Closes #553
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbGrZGkr2WqcAs2DJyMym6
Yaraslaut
force-pushed
the
fix/553-565-completion-value-contract
branch
from
September 19, 2026 20:43
a7b0749 to
8fe9c13
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two tickets that both turn on what
Completion<T>does with a value, and onwhat a wait loop is allowed to leave behind.
Closes #553
Closes #565
#565 — the benchmark writes into a destroyed stack frame
tests/test_server_limits.cpp's throughput benchmark heldstd::atomic<int> doneas a stack local of theBENCHMARKbody and captured it by referenceinto reply callbacks that run on
ThreadPoolExecutorworkers. The 1 s deadlinelets the wait loop exit with replies still in flight; the body returns,
doneis destroyed, and Catch2 re-enters the body for the next sample, constructing a
fresh counter over the same stack slot.
Reproduced first,
clang-tsanpreset, clang 22.1.8,./morph_tests "benchmark: in-process execute round-trip" "[!benchmark]":Two races, both frames in this test — exactly what the issue reported.
After, same command, same binary flags:
Zero TSan output, and the benchmark still reports a number rather than becoming
a no-op. The whole
[limits]set under TSan: 970 assertions, 16 test cases, nowarnings.
Which of the two candidate fixes. The issue named both. I took the
shared_ptrone and kept the deadline, demoted to what it always was inpractice: a benchmark timeout that stops a wedged server becoming a hang. An
unconditional wait removes the lifetime question but trades a slow result for a
hang, and the lifetime question is removable on its own terms — the counter is
heap-allocated and co-owned by every callback, so a straggler writes into a live
object whether or not the deadline was right.
#553 — the
Completion<T>value contractVerdict on the pre-existing design doc
I read
docs/superpowers/specs/2026-09-16-completion-value-contract-design.mdon branch
completion-value-contractbefore designing anything, and adoptedit: the contract as stated, both mechanism changes (erase
onOkasstd::function<void(const T&); giveCompletionState<T>enable_shared_from_this), design A over B for the reason it gives, and its"out of scope" list including leaving
IExecutor::postalone. Its call-siteanalysis held up against the tree.
Three places where I did something different, all additive:
The doc's mechanism does not compile as written for a move-only
T.It leaves
value = std::move(val)insetValue, which goes throughstd::optional<T>::operator=(U&&)and so requiresTto be move-assignableas well as move-constructible. Caught by the
static_assert's own test:a
Probewithoperator=deleted fails to compile withobject of type 'std::optional<Probe>' cannot be assigned because its copy assignment operator is implicitly deleted. Changed tovalue.emplace(std::move(val)),which needs only move-construction and keeps the same strong exception
guarantee (
emplaceleaves the optional disengaged if construction throws).Without this,
std::move_constructible<T>would have been a claim theimplementation quietly did not honour.
The doc counts three sites that reject a move-only
T. There are four—
setValue'sauto savedVal = valand its fan-outsavedFns[i](savedVal),plus
attachThen'sauto savedVal = *valueand its by-copy capture. Measuredagainst master's header with g++ 16.2.1: four
use of deleted function 'std::unique_ptr::unique_ptr(const std::unique_ptr&)'errors atcompletion.hpp:72,:89,:170,:171.Dropped the
tests/compile_checks/entry the doc proposes (a negativecompile test that a by-value handler on a move-only
Tis rejected). Itwould pin a property of
std::function's converting constructor, not ofmorph, and the repo's
compile_checksmechanism is a per-entrytry_compile— a configure-time cost for a check whose subject is thestandard library. The positive half is pinned instead, at runtime, in
test_completion_value_contract.cpp. Say the word and I will add it.I did not reproduce the doc's ns/settle benchmark table. Those numbers were
measured against standalone models rather than a patched header, and the claim
this ticket turns on is the copy count, which is measured below against the real
header. The small-
Tatomic-refcount regression the doc records is real and isdocumented in the spec rather than re-measured.
Before / after, measured
Instrumented
Tcounting copy and move constructions, against the realinclude/morph/core/completion.hpp, g++ 16.2.1-O2, Linux x86-64. N handlersattached before the completion settles, M after; an executor that runs posted
closures inline, so no scheduling is in the numbers.
Copies
const T&beforeconst T&afterN + 2M for every handler signature, before. After: zero for a
const T&handler, exactly one for a by-value handler, independent of handler count and
of which side of the settle the handler attached.
Moves, same runs: 1–8 before (17 in the 3+3 by-value case), a constant 2
after — into
setValue's by-value parameter, then intovalue, with theprvalue elided into
resolve's.Move-only
TWorks.
Completion<std::unique_ptr<int>>instantiates, fans out to threeconst T&handlers across the settle (sum 63 from aunique_ptr<int>(21)), andalso works through the
CallbackScope-gatedthen(scope, fn)overload,including refusing delivery after
requestStop(). On master the same programdoes not compile — four
use of deleted functionerrors, quoted above.IExecutor::postis untouched: the dispatch closure captures ashared_ptr,not the value, so it stays copy-constructible.
Evidence that the new tests fail on the unfixed code
tests/test_completion_value_contract.cpp, with its two move-only cases removedso the rest can be run rather than merely fail to compile, built against
origin/master'scompletion.hpp:The two move-only cases do not compile against that header at all.
Counts are asserted exactly, not as an upper bound — an upper bound absorbs
the regression the file exists to catch.
One existing test replaced, deliberately
test_completion_multi_handler.cpp's "a throwing T copy leaves the stateunsettled with every handler intact" pinned the old shape: it required
setValue(ThrowOnCopy{7, true})to throw, becausesetValuecopied. There isno copy on the value path any more, so it failed — correctly. It is replaced by
two tests rather than deleted:
the same
ThrowOnCopy, armed, settled throughconst T&handlers, requirednot to throw. That turns "zero copies" from a comment into a trap, and it
fails on master.
a new
ThrowOnMovefixture pinning the strong exception guaranteesetValueactually still has —
emplacebefore drainingonOk, so an escape leaves thestate unready with every handler attached, and a later successful settle still
fires them.
What I checked, inline (no
/code-review, no/simplify)valuein the dispatch closures a race? No.valueis write-once:setValueandsetExceptionboth return early whenready, andgrepconfirms nothing else assigns it. The store happens undermtxbefore the closure reaches the executor, and the executor's queuesupplies the happens-before edge. Argued in the header and the spec, and the
full suite is green under TSan.
shared_from_this()always legal here? EveryCompletionState<T>inthe tree is created by
std::make_shared— grepped forCompletionState<across
include/ src/ examples/ tests/; the only non-shared_ptrhits arecomments. A stack-constructed one would
throw std::bad_weak_ptr.logging? No cycle: the closure is destroyed when the executor runs or drops
it. Orphan logging is on the error path only, which is untouched
(
exception_ptris already a refcounted handle — left alone, per the designdoc's out-of-scope list).
.then(/.thenDetached(sites, with theirhandler parameter spellings extracted mechanically. The shapes present are
Tby value,const T&, and oneautoby value(
bridge.hpp:2523) — all three convert tostd::function<void(const T&)>.A handler taking
T&orT&&would not convert; none exists. The twocompletion-chaining sites (
bridge.hpp:2207,:2241) takeR valuebyvalue and still cost one copy — a floor, since
setValue(T)must own avalue, and the spec now says so.
docs/spec/core/completion.mdgains a "Value-handling contract"section; the summary-table row claiming
setValue"moves it only into thelast handler's invocation" — now false — is replaced, as are the
onOkmember type, the four
thensignatures,attachThen's signature, the "Copyvs. move of the value on dispatch" bullets and the fan-out paragraph under
Failure modes.
scripts/check_spec_citations.shpasses.Verification runs
clang-debug(tests + examples)clang-tsan, full ctestclang-asan(ASan + UBSan), full ctestgcc-debugMORPH_BUILD_LADDER=ON MORPH_LADDER_RUNGS=all MORPH_BUILD_QT=ONclang-tidy-diffoverorigin/master...HEADNOLINTs verified by deleting one and watching the finding come back-DMORPH_BUILD_DOCUMENTATION=ONscripts/check_spec_citations.shRebase, and re-verification after it
Rebased onto current master (
70792bee).origin/masteris an ancestor of thebranch head, the tree has zero conflict markers, and the
CHANGELOG.mdoverlapwith #581 is resolved. Files touched, unchanged from the list above:
No overlap with #585's files.
bridge.hpp,executor.hpp, the Qt websocketbackend and its tests are untouched here.
The one thing the rebase actually changed
The call-site audit above was taken before #586 (
70792bee) landedinclude/morph/net/socket_backend.hpp, which createsCompletionState<T>directly and calls
setValue/setExceptionon it. That is new code the auditnever saw, and it is exactly the shape this PR changes.
Checked, not assumed.
MORPH_BUILD_NETisOFFin theclang-debugpreset, sothe ordinary local build does not compile that header; it was syntax-checked
explicitly against the new
completion.hpp, with the real compile flags:Its eleven
CompletionState<T>sites are allstd::make_shared, which is theprecondition the new
shared_from_this()needs, and none of them is a.then(call site, so the erased-handler change cannot reach them.
Independent re-check of the two safety arguments
valueis write-once, so the unlocked read in the dispatch closures issound. Confirmed mechanically rather than by reading prose — across the whole
header there is exactly one write:
shared_from_this()is always legal. EveryCompletionState<T>ininclude/ src/ examples/ tests/is created bystd::make_shared— includingthe three new sites in
socket_backend.hpp. The remaining grep hits areshared_ptrmembers and parameters, not constructions.Mutation testing — are these tests non-vacuous?
Every claim below was produced by building this branch's tests against
origin/master'scompletion.hppand running them.The move-only cases are compile-level assertions and do not compile on
master — which is the point, but it also means they cannot be "run and fail",
so they are reported separately:
A fourth error on master is worth naming on its own, because it confirms the
emplacechange was load-bearing and not a tidy-up.Probedeliberatelydeclares no assignment operator, and master's
setValueneeds one:The counting cases fail on master. With the two move-only cases excised and
Probegiven a move-assignment operator purely so master's header compiles atall, the rest run:
One case passes on both sides, and it is reported rather than claimed.
"a throwing T move leaves the state unsettled with every handler intact" does
not discriminate master from this branch — both orderings keep the strong
exception guarantee, so as evidence for this change it is worth nothing, and
saying otherwise would be the kind of control this repository keeps getting
burned by.
It is not vacuous, though, and that was checked rather than asserted. It pins
the store-before-drain ordering, so it was mutated directly: draining
onOkbefore
value.emplacein the fixed header makes it fail, and nothing else inthe tag does.
Header restored, tree clean, and the tag back to
All tests passed (77 assertions in 9 test cases).Compile time
completion.hppis widely included and #573 records that core's compile budgetis already strained, so this was measured rather than waved past. An isolated TU
including only
morph/core/completion.hpp, clang 22.1.8-std=c++23, threeruns each:
origin/masterThe ranges overlap completely (branch minimum 2368 is below master's maximum
2517). No compile-time cliff, and nothing here worth filing against #573.
Local runs
clang-debugfull buildctestfull suite[issue-553]tagscripts/check_spec_citations.shsocket_backend.hppsyntax checkCompiler cache: this tree was pinned to
ccacheby an earlier configure and hasbeen moved back onto
fastcache-ccagainst the daemon on127.0.0.1:6674— all172
LAUNCHERlines inbuild.ninja, zeroccache.Filed in passing
#592 —
cmake/CompileCache.cmaketells you to reconfigure with--freshtochange a stuck launcher, which discards the whole build tree, when
-UCMAKE_C_COMPILER_LAUNCHER -UCMAKE_CXX_COMPILER_LAUNCHERdoes the same thingand keeps every object. Reproduced both paths. Not folded into this PR.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GS5K2vqZtC4xbRiGJHT7jH