morph::async::Completion<T> is a move-only handle representing the eventual
result of an asynchronous operation. It delivers a single success value or an
error to callbacks registered via then() / onError(), posting the
callback to the executor supplied at construction (if any) so it runs on the
intended thread (e.g. the GUI thread). When the executor is nullptr,
callbacks are never posted — the handle is a write-only endpoint for the
producer. An undelivered value is dropped silently, but an undelivered
error is preserved: it surfaces through the destructor's orphan logger rather
than vanishing (see Failure modes).
- Shared state —
CompletionState<T> - Orphan detection
- Move-only handle —
Completion<T> - Settleable promise seam —
Completion<T>::Promise - Thread safety
- Failure modes
- Client-side execute deadline
- Lifetime and stop gating
- Empty state
- API reference
- Design decisions
- Limitations
- Cross-references
detail::CompletionState<T> is the heap-allocated backing that both the
producer and the consumer reference through std::shared_ptr. All mutation is
guarded by std::mutex mtx.
| Member | Type | Purpose |
|---|---|---|
mtx |
std::mutex |
Guards all state and callback registration |
value |
std::optional<T> |
The success value, set once |
error |
std::exception_ptr |
The error, set once via setException |
ready |
bool |
true once either value or error is set |
onOk |
std::vector<std::function<void(T)>> |
Stored success callbacks, in attachment order; moved out on dispatch |
onErr |
std::vector<std::function<void(std::exception_ptr)>> |
Stored error callbacks, in attachment order; moved out on dispatch |
onErrAttached |
bool |
Suppresses orphan logging when true; set to (cbExec != nullptr) — never set on a null-executor state |
cbExec |
::morph::exec::IExecutor* |
Executor for callback dispatch; may be nullptr |
Setting a value or exception. setValue(T) and setException(exception_ptr)
are called by the producer. If the state is already ready, the call is a no-op
(only the first result wins). When one or more callbacks are already registered
(via attachThen / attachOnError), a fire-once closure invoking every
registered callback, in attachment order, is built under the lock and posted to
the executor outside the lock, so no callback ever runs under the mutex. The
closure is posted only when cbExec != nullptr; with a null executor it is
built but never delivered. Each individual handler invocation inside that
closure is wrapped in its own try { ... } catch (...) { logError(...); }, so a
throwing handler is logged and skipped without preventing the handlers attached
after it from running — fan-out means every attached handler gets its turn,
independent of an earlier one misbehaving.
setException additionally sets onErrAttached = (cbExec != nullptr) — but
only along the branch where at least one onErr handler was already
registered. It marks the error handled (suppressing the orphan logger) only
when an executor exists to actually deliver it. With a null executor the
handlers are present but the closure is never posted, so onErrAttached stays
false and the abandoned error still reaches the destructor's orphan logger
rather than vanishing silently.
Attaching callbacks — composes, does not overwrite. attachThen(handler)
and attachOnError(handler) are called by Completion::then() / onError().
If the state is already ready with the corresponding kind of result (value for
then, exception for onError), a fire-now closure for this handler is built
and posted to the executor immediately (this handler alone — earlier handlers,
if any, already fired when the state became ready, or will each fire from their
own immediate call). Otherwise, if the state is not yet ready, the handler is
appended to onOk / onErr — every handler attached while the state is
still pending is kept, not just the most recent one. When the result finally
arrives, setValue/setException invokes all of them, in the order they were
attached, from one posted closure. If the state is already ready with the
opposite kind of result (e.g. attachThen on an error state, or
attachOnError on a value state), neither branch runs: no closure is built and
no handler is stored — the attach is a silent no-op, for that call only (it does
not affect any other handler already stored).
Copy vs. move of the value on dispatch. The two dispatch paths handle the stored value differently, and the difference is observable:
- Set-after-attach (
setValuefinds one or more already-registeredonOkhandlers): every handler but the last is invoked with a copy of the value; only the final handler in attachment order receives it moved (std::move(savedVal)). After dispatch,valueitself holds a moved-fromTonly if a subsequentattachThen()re-attach reads it (see below) — the in-flight closure's own copies are unaffected by that. - Attach-after-ready (
attachThenfires now against a settled value): the value is copied (savedVal = *value), leavingvalueintact.
The fire-now copy is what makes a repeated then() on an already-settled value
state fire again with the same result (see Failure modes); a
move there would hand the second handler a moved-from value. Errors have no such
asymmetry — an exception_ptr is cheap to copy and is copied for every handler
on both paths, so error is never emptied.
attachOnError sets onErrAttached = (cbExec != nullptr) unconditionally on
entry, before inspecting the state. So attaching an error handler on a
null-executor state does not suppress orphan logging: the handler will never
be posted, so the error is preserved for the destructor's orphan logger instead
of being both dropped and silenced.
If a CompletionState is destroyed while ready == true, error is set, and
onErrAttached is false, the destructor logs the unhandled exception via
::morph::log::logError with the prefix [orphan]. The exception is
re-thrown solely to extract a message:
- If it derives from
std::exception, the log reads[orphan] unhandled exception: <what()>. - Otherwise (a
catch (...)branch), the log reads[orphan] unhandled unknown exception.
The logError calls need no local guard: morph::log's helpers are
noexcept (logger.md, "Failure modes"), which is what makes them safe to
call from an implicitly-noexcept destructor at all. The variadic overload
is used deliberately — it formats inside that guarantee, whereas building the
message by concatenation at the call site would allocate outside it and could
still escape. If the record cannot be emitted, the logging layer counts it in
morph::log::droppedLogRecords().
This prevents silent loss of error information when a Completion goes out of
scope without an onError handler.
onErrAttached is set by both attachOnError (unconditionally, on entry) and
setException (on the branch where an onErr handler was already registered),
but in both cases the value written is (cbExec != nullptr), not an
unconditional true. The consequences:
- With an executor: once an error handler has been attached (or an error has
been dispatched to an already-registered handler),
onErrAttachedistrueand the destructor treats the error as handled — no orphan is logged. - With a null executor: the handler can never be posted, so
onErrAttachedstaysfalseand the destructor still logs the orphan. This closes a hole where a null-executor error handler used to both drop the error (no executor to post on) and silence the orphan logger, losing the error entirely.
In short, orphan logging is suppressed precisely when the error has a real delivery path; if the error can never be delivered, it is never silenced.
Completion<T> wraps a shared_ptr<CompletionState<T>>. Move-only — no copy
construction or copy assignment. The default constructor produces an empty
(no-op) completion with a null state pointer.
The two-argument constructor takes a shared state and an executor pointer,
storing the executor in state->cbExec. All subsequent then() / onError()
calls forward to the state's attachThen / attachOnError, which use cbExec
for posting.
then() and onError() return *this for chaining:
completion
.then([](int val) { /* ... */ })
.onError([](std::exception_ptr e) { /* ... */ });Completion<T>::makeSettleable(execPtr) is a static factory returning a
std::pair<Completion<T>, Completion<T>::Promise> that share one freshly
allocated CompletionState<T>. It is the public counterpart to hand-building a
Completion<T> from a detail::CompletionState<T> the way Bridge and the
backends do internally (see Shared state) —
useful for test code (or any caller outside the framework's own producer code)
that needs a Completion<T> it can resolve or reject on demand, without a full
Bridge/IBackend round trip and without ever naming
morph::async::detail::CompletionState<T> (issue #55).
auto [completion, promise] = morph::async::Completion<int>::makeSettleable(&exec);
completion.then([](int val) { /* ... */ });
// ... later, from producer code:
promise.resolve(42); // or promise.reject(someExceptionPtr);Promise is move-only, mirroring Completion<T>, and exposes exactly two
methods:
resolve(T val)— calls the shared state'ssetValue(std::move(val)).reject(std::exception_ptr exc)— calls the shared state'ssetException(exc).
Both are no-ops if the state is already settled (first-result-wins, same as
CompletionState<T>::setValue/setException) or if this Promise was itself
moved from (mirroring Completion<T>::then()/onError()'s null-state no-op —
see Empty state). Both are safe to call from any thread, since
they forward directly to the mutex-guarded CompletionState<T> methods.
Promise never exposes CompletionState<T> in its own interface — its
constructor is private, reachable only via the friended makeSettleable() —
so a caller can settle a Completion<T> on demand without the detail::
namespace ever appearing in their code.
then()andonError()may be called from any thread — the mutex guardsvalue,error,ready, and the callback slots (onOk/onErr), so registration and result-setting race safely.- Callbacks are never invoked directly from the producing thread. They are
posted to
cbExecviaIExecutor::post()and run on the executor's thread. - If
cbExecisnullptr, no callback is posted (the fire-now/fire-once closure is built but never delivered, and stored callbacks are never invoked). See Failure modes for what happens to an abandoned error in this case.
cbExec is not mutex-guarded. The mtx protects value / error /
ready / onOk / onErr, but cbExec is read outside the lock (after the
scoped lock is released) in setValue, setException, attachThen, and
attachOnError. This is safe only because of a happens-before requirement, not
a lock: the Completion<T> handle writes cbExec in its constructor, and the
state must be fully constructed (with its executor assigned) before it is
published to any producer or consumer thread. Once published, cbExec is never
reassigned. There is no atomic and no lock around it — the ordering guarantee is
structural (construct-then-share), not enforced at runtime.
These are the sharp edges of the single-shot design. None of them raise or throw — they are silent by construction.
-
Fan-out on attach, not overwrite. Each state holds a
std::vectorof success handlers (onOk) and astd::vectorof error handlers (onErr). A second, third, ...then()(oronError()) attached while the state is not yet ready is appended, not swapped in — every handler attached before readiness runs when the result arrives, in the order it was attached. This closes the earlier "last-writer-wins" foot-gun (issue #59), where a secondonError()on the same still-pendingCompletionsilently discarded the first handler and, becauseonErrAttachedwas still set, suppressed the orphan logger too — losing the error's diagnostic entirely. -
Mismatched attach on a ready state is a silent no-op.
then()on a state that is alreadyreadywith an error does nothing — no closure, no stored handler, no error surfaced to thethenhandler. Symmetrically,onError()on a state that is alreadyreadywith a value does nothing. Only an attach that matches the settled outcome (or precedes readiness) has any effect. This is per-call: it never removes or otherwise disturbs any handler already stored from an earlier, matching attach. -
Null-executor error drop, but no silencing. With
cbExec == nullptr, any attached or pending error handlers are never delivered — there is no executor to post them on. Crucially,onErrAttachedis leftfalsein that case (it is set to(cbExec != nullptr)), so the abandoned error still reaches the destructor's orphan logger. The error is undelivered but never lost: it surfaces as an[orphan]log line instead. (A null-executor value is simply dropped with no diagnostic — only errors have orphan logging.) -
Attaching after delivery re-fires against the settled state. Once every stored handler has fired (the vector was moved out on dispatch), a further
then()/onError()call is governed by the rules above against the now-readystate — i.e. a matching-outcome attach fires immediately with the settled result, a mismatched one is a no-op. A latethen()fires with a copy of the value (the fire-now path copies; see Shared state), so the value is not consumed by any fire-now dispatch. If the value was instead delivered via the set-after-attach path (the last stored handler received it moved), a subsequentthen()attach still fires, but against the now moved-fromvalue— sinceattachThen's fire-now path reads*valuedirectly, not from the (already-emptied) handler vector.
Nothing in Completion<T> itself imposes a time limit: a state that no producer
ever settles simply stays pending forever, and its handle's callbacks never
fire. For an in-process LocalBackend that is unreachable, but across a wire a
request can genuinely disappear — a connection that dropped between send and
reply, or a server that hangs. In those cases no reply of any kind comes back,
so no layer below the caller has anything to resolve the Completion with.
(A frame refused by QtWebSocketServerConfig::messagesPerSecond's rate limiter
used to belong on that list. It no longer does: the transport answers it with an
err "rate limited" addressed to the frame's own callId, so the caller's
Completion fails rather than hanging. A deadline is still worth arming for the
two cases above, which no reply can cover.)
Bridge::setExecuteDeadline(std::chrono::milliseconds) closes that hole.
Opt-in, default disabled. The deadline defaults to
std::chrono::milliseconds{0}, which means "no deadline" and reproduces the
pre-existing behavior exactly — a Bridge that never calls the setter behaves
as it always did, and spawns no extra thread. The current value is readable via
Bridge::executeDeadline().
Single-threaded WebAssembly. TimeoutScheduler has a second build,
selected by #if defined(__EMSCRIPTEN__) && !defined(__EMSCRIPTEN_PTHREADS__),
that uses the browser's own setTimeout (emscripten_async_call) instead of a
thread and fires its callbacks on the main thread — the same thread the Qt
event loop and every QtExecutor-posted completion callback already run on.
This is not a degradation switch: deadlines still fire, with the same
first-result-wins race and the same ClientTimeoutError. It exists because a
wasm_singlethread Qt build (what .github/workflows/wasm-ladder.yml installs
and what cmake/morph_add_rung.cmake builds against, with no -pthread) links
Emscripten's non-pthread pthread_create stub, so constructing a std::thread
throws std::system_error at runtime — which would have made
setExecuteDeadline unusable from a browser tab, and with it
examples/common/gui/event_poller.hpp, whose constructor calls it
unconditionally. Two behavioural differences, both documented in
timeout_scheduler.hpp's own @file comment: callbacks are never concurrent
with the caller, and cancel() releases the callback immediately but leaves the
underlying browser timer to elapse harmlessly rather than clearing it. This
build has never been compiled or run in this repository — no Emscripten
toolchain is available here; its only verification is the ladder-wasm CI
compile gate.
Mechanics. Every executeVia() call made while a non-zero deadline is
installed arms a timer on a Bridge-owned
morph::async::detail::TimeoutScheduler (a single background thread — or, in a
single-threaded WASM build, a browser timer; see above — created lazily on the
first call that enables a deadline and torn down with the Bridge; the same
class RemoteServer uses for its server-side LimitPolicy::executeTimeout). The timer's callback captures only the typed
CompletionState — never the Bridge — and resolves it with
morph::backend::ClientTimeoutError. The real reply and the timer therefore
race, and whichever settles the state first wins, because setValue /
setException are no-ops once the state is ready (see
Failure modes and the first-result-wins row in
Design decisions). A real reply that arrives after the
deadline already fired is silently discarded — it is an ordinary late write to
an already-resolved state, not an error condition. Conversely, a reply that
arrives first disarms the timer as the first statement of the completion
callback, before any onResult / publishResult fan-out work, so a slow
subscriber cannot open a window for the timer to fire against a result already
in hand.
The deadline is armed only for real dispatches. executeVia()'s fast-fail path
for an unbound handler resolves its Completion synchronously before the timer
block is reached, so no timer is created for it.
The disarm is guarded on the same Bridge liveness token the rest of the
completion callback uses: the callback can in principle run after ~Bridge()
(the backend may be co-owned and outlive the Bridge). Skipping the disarm in
that case is harmless — ~TimeoutScheduler drops still-pending entries without
firing them.
ClientTimeoutError vs. TimeoutError. Both live in morph::backend and
both derive from std::runtime_error, but they report different facts:
| Type | Raised by | Means |
|---|---|---|
TimeoutError |
The server, as an explicit err "timeout" reply when LimitPolicy::executeTimeout elapses |
The request was received and the action is running (morph never interrupts an in-flight Model::execute); the server chose to stop making the caller wait. |
ClientTimeoutError |
The client, when Bridge::setExecuteDeadline's duration elapses |
Nothing came back at all. Whether the server ever received the request, is still processing it, or replied over a connection that had already dropped is unknown. |
The practical consequence for callers: TimeoutError confirms the action is
in flight server-side, so a blind retry risks a duplicate. ClientTimeoutError
confirms nothing, so a retry must be idempotent (or reconciled) either way.
A deadline bounds the caller's wait, never the work. It does not cancel the
request — see Limitations, "No cancellation". The server-side
counterpart is documented in backend.md under LimitPolicy.
Because every callback is delivered through an executor, the receiver can be
destroyed — or simply lose interest — between attaching a handler and the
handler running. Completion<T> therefore accepts a
morph::async::CallbackScope (or one of its
CallbackTokens) as an optional first argument:
completion.then(_callbacks, [this](Result r) { render(r); })
.onError(_callbacks, [this](std::exception_ptr e) { showError(e); });The gated handler runs only if, at delivery time, the scope is both alive and
not stopped. The full semantics — the requestStop() / reset() /
destruction verbs, the Active / Stopped / Expired states, and the
executor-affine-versus-advisory thread-safety boundary — live in
callback_scope.md. What matters here is how the gate
interacts with this file's own machinery:
- Nothing in
CompletionState<T>changes. The gate is wrapped around the handler at attach time, so it composes with handler fan-out, with the attach-after-ready fire-now path and withcbExecmarshalling exactly as the ungated form does. - A suppressed error still counts as handled.
onError(scope, fn)setsonErrAttachedexactly asonError(fn)does, so an error whose delivery the scope then refuses does not re-arm the destructor's orphan logging (Orphan detection). Suppression is a deliberate act by the receiver, not a silently dropped error. - A refused handler is destroyed, not leaked. The wrapper and its captures are released with the posted closure, on the delivery executor.
- The ungated spellings are unchanged and undeprecated.
then(fn)/onError(fn)behave exactly as before;thenDetached(fn)/onErrorDetached(fn)are the same attachments under a name that says the omission was deliberate and keeps it greppable in review.
This gates delivery, never the work. See Limitations for why that distinction is kept sharp.
A default-constructed Completion has a null _state pointer. then() and
onError() are no-ops (they check for nullptr and return *this). The
state() accessor returns nullptr. This is used for placeholder completions
that will never signal.
| Member | Signature | Notes |
|---|---|---|
| default ctor | Completion() = default |
Empty, no-op completion (null state). |
| value ctor | Completion(shared_ptr<CompletionState<T>>, IExecutor*) |
Backed by user-supplied state; executor may be nullptr. |
| move ctor | Completion(Completion&&) noexcept = default |
Transfers state ownership. |
| move assign | Completion& operator=(Completion&&) noexcept = default |
Transfers state ownership. |
| copy ctor | Completion(Completion const&) = delete |
Move-only handle. |
| copy assign | Completion& operator=(Completion const&) = delete |
Move-only handle. |
then(handler) |
Completion& then(std::function<void(T)>) |
Registers success callback; returns *this for chaining. |
then(scope, handler) |
Completion& then(CallbackScope const&, std::function<void(T)>) |
As above, gated on the scope's liveness and stop state (see Lifetime and stop gating). |
then(token, handler) |
Completion& then(CallbackToken, std::function<void(T)>) |
Token-taking form of the above; a default-constructed token suppresses unconditionally. |
thenDetached(handler) |
Completion& thenDetached(std::function<void(T)>) |
Exactly then(handler), spelled so a deliberately ungated callback is greppable. |
onError(handler) |
Completion& onError(std::function<void(std::exception_ptr)>) |
Registers error callback; returns *this for chaining. |
onError(scope, handler) |
Completion& onError(CallbackScope const&, std::function<void(std::exception_ptr)>) |
As above, gated on the scope. Still suppresses orphan logging: a scope-refused error counts as handled. |
onError(token, handler) |
Completion& onError(CallbackToken, std::function<void(std::exception_ptr)>) |
Token-taking form of the above. |
onErrorDetached(handler) |
Completion& onErrorDetached(std::function<void(std::exception_ptr)>) |
Exactly onError(handler), under the deliberate-omission spelling. |
state() |
shared_ptr<CompletionState<T>> state() const |
Returns the underlying shared state (advanced / internal use). |
makeSettleable(execPtr) |
static std::pair<Completion<T>, Promise> makeSettleable(IExecutor*) |
Public settleable-promise factory (see Settleable promise seam). |
| Member | Signature | Notes |
|---|---|---|
| move ctor | Promise(Promise&&) noexcept = default |
Transfers state ownership. |
| move assign | Promise& operator=(Promise&&) noexcept = default |
Transfers state ownership. |
| copy ctor | Promise(Promise const&) = delete |
Move-only handle. |
| copy assign | Promise& operator=(Promise const&) = delete |
Move-only handle. |
resolve(val) |
void resolve(T) |
Settles the paired Completion<T> with a value; no-op if already settled or moved-from. |
reject(exc) |
void reject(std::exception_ptr) |
Settles the paired Completion<T> with an error; no-op if already settled or moved-from. |
| Member | Signature | Notes |
|---|---|---|
setValue(T) |
void setValue(T) |
Producer-side; no-op if already ready. Posts one closure invoking every registered success handler, in attachment order, if any were registered. |
setException(exception_ptr) |
void setException(std::exception_ptr const&) |
Producer-side; no-op if already ready. Posts one closure invoking every registered error handler, in attachment order, if any were registered. |
attachThen(function<void(T)>) |
void attachThen(std::function<void(T)>) |
Consumer-side; fires immediately (this handler only) if ready with value, else appends to the stored handler list. |
attachOnError(function<void(exception_ptr)>) |
void attachOnError(std::function<void(std::exception_ptr)>) |
Consumer-side; fires immediately (this handler only) if ready with error, appends to the stored handler list if not yet ready, no-op if ready with a value. Sets onErrAttached = (cbExec != nullptr), so orphan logging is suppressed only when an executor exists to deliver on. |
| destructor | ~CompletionState() |
Orphan-detection: logs unhandled exceptions when destroyed with an error and no onErr attached. |
| Decision | Choice | Why |
|---|---|---|
| Callback dispatch | Posted to IExecutor, never direct |
Ensures callbacks run on the intended thread (e.g. GUI/main thread) regardless of which thread completes the operation. |
| Mutex scope | Lock held only during state access, not during callback invocation | Callback closures are built under the lock but invoked outside it, preventing callback re-entrancy into the mutex and avoiding deadlock. |
| Orphan detection | Destructor logs through logError |
Prevents silent loss of error information when a Completion is destroyed without an onError handler. The exception is re-thrown just to extract a message (what() for a std::exception, a generic string otherwise), which is logged; the logError call is itself wrapped in an empty catch (...) so the noexcept destructor never lets an exception escape. |
| No executor callback | cbExec == nullptr disables posting |
A Completion without an executor is a write-only endpoint — the producer can set a value or error, but stored callbacks are never invoked. This is by design for internal patterns where the consumer never attaches. An abandoned error is not silenced, though: onErrAttached tracks (cbExec != nullptr), so a null-executor error still reaches the destructor's orphan logger. |
| First-result-wins | setValue/setException are no-ops after ready |
An asynchronous operation should complete exactly once; subsequent calls are silently ignored. |
| Move-only handle | Completion is move-only, CompletionState is shared via shared_ptr |
The handle is owned by one consumer at a time; the shared state is owned jointly by the producer and any consumer that has moved the handle. |
| Empty completion | Null state pointer makes then/onError no-ops |
Default-constructed Completion is a safe placeholder that never signals. |
| Value copy on fire-now | attachThen copies *value; setValue moves it only into the last handler's invocation |
The set-after-attach path copies the value into every handler but the last (moving only into the final call), so no earlier handler observes a moved-from value and the value is still consumed exactly once overall. The attach-after-ready path must copy so value stays intact and a repeated then() on a settled state can still fire with the result. |
| Handler fan-out | onOk/onErr are std::vectors, appended to on each attach |
Fixes issue #59: a second onError() (or then()) on the same still-pending Completion used to silently replace the first handler in a single-slot field. Composing (invoking every attached handler, in order) matches the mental model of an observer list and is what most call sites composing behavior via repeated attach actually expect. |
| Per-handler exception isolation | Each composed handler invocation is wrapped in its own try/catch (...), logged via logError and swallowed |
Fan-out means every attached handler should get its turn regardless of what an earlier one does. Without per-handler isolation, one throwing handler would unwind the whole posted closure and silently skip every handler attached after it — turning a single misbehaving consumer into an outage for unrelated ones sharing the same Completion. |
| Public settleable-promise seam | Completion<T>::Promise, reachable only via makeSettleable() |
Fixes issue #55: test code needing a Completion<T> it can resolve/reject on demand had no seam except reaching into morph::async::detail::CompletionState<T> directly. Promise's constructor is private and friended only to Completion<T>, so detail::CompletionState<T> never has to appear in a caller's own code. |
Completion<T> is deliberately a leaf callback primitive, not a general
future/promise or a monadic async type. Its scope is narrow by design:
- No transformation, no chaining.
then()returns*this(the sameCompletion<T>&), purely so athen().onError()pair reads fluently. It does not return a newCompletion<U>for a transformed result — there is noT → Umapping and no way to chain one asynchronous step onto another. To sequence work, the consumer must start a fresh operation from inside the handler. - No
co_await.Completion<T>is not an awaitable; it has no coroutine promise/awaiter machinery. Consumption is callback-only. - No work cancellation. There is no handle to cancel an outstanding
operation; once started, it runs to completion (or is abandoned).
Bridge::setExecuteDeadline(see Client-side execute deadline) is not an exception to this: it bounds how long the caller waits by resolving the state early, and does nothing to the work still in flight underneath. Delivery, by contrast, can be stopped — see Lifetime and stop gating. The distinction is sharp and deliberate: aCallbackScopesays "do not hand me this result", never "stop producing it". Work-side cancellation is issue #116. - Single consumer handle, but multiple handlers per outcome. The
Completion<T>handle itself is move-only — only one owner at a time — but each state'sonOk/onErrare vectors, so repeatedthen()/onError()calls on the same handle (or theCompletion&it returns for chaining) all compose: every handler attached before readiness runs, in attachment order (see Failure modes). This is in-process fan-out to multiple callbacks on one handle, not multicast to multiple handles — there is still only oneCompletion<T>per operation. - No synchronous blocking. There is no
wait()orget().
Orphan logging fires from ~CompletionState, not from handle destruction.
The orphan check lives in CompletionState::~CompletionState, which runs when
the last shared_ptr to the state drops — jointly held by the producer and
any consumer that moved the handle. Destroying a Completion<T> handle does not
by itself trigger orphan logging if the producer still holds a reference to the
state; the log is emitted only when the state itself is finally destroyed with a
ready error and onErrAttached == false.
- Work cancellation — there is no mechanism to cancel an outstanding
operation. Stopping delivery of its result is
CallbackScope; the two are different things. - Multiple values —
Completion<T>is a single-result primitive. - Synchronous blocking — there is no
wait()orget(); the API is callback-only. - Transformation / composition — see Limitations.
executor.md—IExecutorand its implementations;cbExecis the executor on which every callback is posted.logger.md—morph::log::logError, the error-handling sink used by orphan detection when an error is abandoned.backend.md— backends resolve the pendingCompletionwhen a response arrives; alsomorph::backend::LimitPolicy::executeTimeout, the server-side counterpart to the client-side execute deadline, andTimeoutError/ClientTimeoutError.error_handling.md— the framework-wide error-propagation story; the orphan-logging contract detailed in this file is summarised there alongside the executor and backend error paths.bridge.md—BridgeHandler<M>producesCompletion<T>fromexecute()and posts callbacks on the GUI executor.callback_scope.md—CallbackScope/CallbackToken, the gate behind thethen(scope, fn)/onError(scope, fn)overloads and thethenDetached/onErrorDetachedspellings.- Settleable promise seam —
Completion<T>::makeSettleable(), the public seam test code uses in place of aBridge/IBackendround trip.