You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
morph::async::Completion<T> does not state what it does with T — what type
requirements it imposes, how many times it copies the value, or which of those
copies a caller can avoid. Measured, the answer is worse than it needs to be,
and one part of it is a plain defect.
Design written up in full at docs/superpowers/specs/2026-09-16-completion-value-contract-design.md
(branch completion-value-contract).
What was measured
Instrumented T counting copy/move constructions, N handlers attached before
the completion settles and M after. Real output:
The N is paid whether or not the handler wants it. Handlers are erased
as std::function<void(T)>, so the value is materialised once per handler
even when the handler only reads it.
One of the 2M is a defect.attachThen's fire-now path copies the
stored value into savedVal, then the closure captures savedValby
copy before moving it into the handler. One of the two buys nothing. This
is present on master and is not fixed by Fix a permanent RemoteServer deadlock and a lost completion value #546.
T must also be copy-constructible. Completion<std::unique_ptr<int>> does not
compile — three sites, in setValue's fan-out and attachThen's fire-now
path. Fixing those alone would not be enough: IExecutor::post takes std::function<void()>, std::function requires a CopyConstructible target,
and the dispatch closure captures a T.
Proposed contract
std::move_constructible<T> is the whole type requirement. Copyability
becomes an obligation of the individual handler that takes T by value,
reported where that handler is written.
Exactly one copy per by-value handler; zero per const T& handler,
independent of handler count and of whether they attached before or after
settling.
The value is observed, never consumed — which is what morph#520 already
requires, made structural rather than conventional.
Mechanism is two changes: onOk becomes std::vector<std::function<void(const T&)>>, and CompletionState gains enable_shared_from_this so the dispatch closure reads the value in place.
One erased type accepts every spelling callers already write — verified:
so every existing .then(...) call site keeps compiling. Move-only T then
works as a side effect, with no change to IExecutor::post.
Measured impact
Per settle, handlers taking const T&. large is a 20-field struct of
heap-allocated strings, small an int wrapper. All variants heap-allocate
the state, as morph does.
Small h=0 today= 14.6 A= 17.6 B= 23.0
Small h=1 today= 43.0 A= 55.2 B= 54.2
Small h=3 today= 70.9 A= 82.5 B= 82.9
Large h=0 today= 314.8 A= 335.1 B= 331.3
Large h=1 today= 1140.4 A= 370.0 B= 369.7
Large h=3 today= 2103.0 A= 398.3 B= 400.5
A = enable_shared_from_this, B = shared_ptr<const T> storage. 5.3x on a
large T with three handlers, 3.1x with one. A and B are indistinguishable
once handlers are attached; B costs 8.4ns for an allocation that buys nothing
when none are, which is why A.
Costs, recorded rather than hidden: small T loses ~12ns per settle to an
atomic refcount pair; the large-T win needs const T& handlers (by-value
measures 678ns/1323ns); and then()'s parameter type changes — source
compatible for lambdas and for existing std::function<void(T)> objects, but
public.
Verification status
Measured, on batch/519-520 (PR #546) at aa185d8b, with GCC 15 -O2 on
Linux x86-64:
the copy/move counts, with an instrumented T against the real header;
the benchmark table, against faithful standalone models of the three
value paths — not against a patched completion.hpp;
that std::function<void(const T&)> accepts all three handler spellings;
that a move-only T fans out to several const T& handlers under design A.
Not verified: no implementation exists yet, so none of this is measured
against real morph dispatch; the Bridge end-to-end copy count is reasoned from
reading the code, not counted; and the numbers are one machine, one compiler.
Baseline matters here. These are measured against the tree with #546
applied. On master (2407ffd) setValue still does auto savedVal = std::move(*value), so a handler attached after settling
receives a moved-from husk — morph#520, fixed in #546 and not before it. This work depends on #546 landing first, or "the value is observed, never
consumed" would be built on a setValue that consumes it.
Bridge
The action path is already copy-free and the contract records it rather than
changing it: Executor is std::function<Completion<std::string>(void*, std::string_view)>, executeImpl stores one shared_ptr<Action>, and models take const Action&.
The only cost is Bridge::execute(Action action) by value — one move if the
caller moves. The large-DTO case is Completion<Result> on local typed
dispatch, which the change above covers directly.
Audited all 309 .then( call sites: every handler that moves its parameter
takes it by value first, so none breaks. bridge.hpp:2207 and :2241 chain
one completion into another, where setValue(T) must own a value — one copy
there is a floor, not an oversight.
What would close this
The copy budget holds exactly (not "at most") under a counting fixture for
0/1/3 const T& handlers, by-value handlers, late attachers and mixed sets.
Completion<std::unique_ptr<int>> instantiates and fans out.
A by-value handler on a move-only T is rejected, pinned in tests/compile_checks/.
Each new test observed to fail against the unfixed code before being kept.
What would change the verdict
If real morph payloads are dominated by Completion<std::string> of
already-serialised JSON, the large-T win is mostly theoretical and the
~12ns small-T regression is the only effect that lands. A copy-count
measurement over a real dispatch would settle it, and has not been done.
If the ~12ns matters somewhere measured, a sizeof(T) fast path becomes
worth its second code path through setValue — deliberately rejected here,
since that function is already where morph#520 came from.
morph::async::Completion<T>does not state what it does withT— what typerequirements it imposes, how many times it copies the value, or which of those
copies a caller can avoid. Measured, the answer is worse than it needs to be,
and one part of it is a plain defect.
Design written up in full at
docs/superpowers/specs/2026-09-16-completion-value-contract-design.md(branch
completion-value-contract).What was measured
Instrumented
Tcounting copy/move constructions, N handlers attached beforethe completion settles and M after. Real output:
The budget is N + 2M. Two problems:
Nis paid whether or not the handler wants it. Handlers are erasedas
std::function<void(T)>, so the value is materialised once per handlereven when the handler only reads it.
2Mis a defect.attachThen's fire-now path copies thestored value into
savedVal, then the closure capturessavedValbycopy before moving it into the handler. One of the two buys nothing. This
is present on
masterand is not fixed by Fix a permanent RemoteServer deadlock and a lost completion value #546.Tmust also be copy-constructible.Completion<std::unique_ptr<int>>does notcompile — three sites, in
setValue's fan-out andattachThen's fire-nowpath. Fixing those alone would not be enough:
IExecutor::posttakesstd::function<void()>,std::functionrequires a CopyConstructible target,and the dispatch closure captures a
T.Proposed contract
std::move_constructible<T>is the whole type requirement. Copyabilitybecomes an obligation of the individual handler that takes
Tby value,reported where that handler is written.
const T&handler,independent of handler count and of whether they attached before or after
settling.
requires, made structural rather than conventional.
Mechanism is two changes:
onOkbecomesstd::vector<std::function<void(const T&)>>, andCompletionStategainsenable_shared_from_thisso the dispatch closure reads the value in place.One erased type accepts every spelling callers already write — verified:
so every existing
.then(...)call site keeps compiling. Move-onlyTthenworks as a side effect, with no change to
IExecutor::post.Measured impact
Per settle, handlers taking
const T&.largeis a 20-field struct ofheap-allocated strings,
smallanintwrapper. All variants heap-allocatethe state, as morph does.
A =
enable_shared_from_this, B =shared_ptr<const T>storage. 5.3x on alarge
Twith three handlers, 3.1x with one. A and B are indistinguishableonce handlers are attached; B costs 8.4ns for an allocation that buys nothing
when none are, which is why A.
Costs, recorded rather than hidden: small
Tloses ~12ns per settle to anatomic refcount pair; the large-
Twin needsconst T&handlers (by-valuemeasures 678ns/1323ns); and
then()'s parameter type changes — sourcecompatible for lambdas and for existing
std::function<void(T)>objects, butpublic.
Verification status
Measured, on
batch/519-520(PR #546) ataa185d8b, with GCC 15-O2onLinux x86-64:
Tagainst the real header;value paths — not against a patched
completion.hpp;std::function<void(const T&)>accepts all three handler spellings;Tfans out to severalconst T&handlers under design A.Not verified: no implementation exists yet, so none of this is measured
against real morph dispatch; the Bridge end-to-end copy count is reasoned from
reading the code, not counted; and the numbers are one machine, one compiler.
Baseline matters here. These are measured against the tree with #546
applied. On
master(2407ffd)setValuestill doesauto savedVal = std::move(*value), so a handler attached after settlingreceives a moved-from husk — morph#520, fixed in #546 and not before it.
This work depends on #546 landing first, or "the value is observed, never
consumed" would be built on a
setValuethat consumes it.Bridge
The action path is already copy-free and the contract records it rather than
changing it:
Executorisstd::function<Completion<std::string>(void*, std::string_view)>,executeImplstores oneshared_ptr<Action>, and models takeconst Action&.The only cost is
Bridge::execute(Action action)by value — one move if thecaller moves. The large-DTO case is
Completion<Result>on local typeddispatch, which the change above covers directly.
Audited all 309
.then(call sites: every handler that moves its parametertakes it by value first, so none breaks.
bridge.hpp:2207and:2241chainone completion into another, where
setValue(T)must own a value — one copythere is a floor, not an oversight.
What would close this
0/1/3
const T&handlers, by-value handlers, late attachers and mixed sets.Completion<std::unique_ptr<int>>instantiates and fans out.Tis rejected, pinned intests/compile_checks/.What would change the verdict
Completion<std::string>ofalready-serialised JSON, the large-
Twin is mostly theoretical and the~12ns small-
Tregression is the only effect that lands. A copy-countmeasurement over a real dispatch would settle it, and has not been done.
sizeof(T)fast path becomesworth its second code path through
setValue— deliberately rejected here,since that function is already where morph#520 came from.