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
SynchronousBackendAdapter::cancelPending (include/morph/core/backend.hpp:1039)
is a one-line forward to the wrapped backend:
/// @brief Forwards to the wrapped backend./// @param exc Exception delivered to every still-pending completion.voidcancelPending(const std::exception_ptr& exc) override { _inner->cancelPending(exc); }
But the two verbs this adapter exists to reshape — bindModel and promoteModel — do not produce completions the wrapped backend knows about. They
produce a Completion whose Promise is captured by a task posted to the
adapter's own private StrandExecutor (_control, backend.hpp:1088), via dispatch() at backend.hpp:1067-1082:
Nothing in cancelPending reaches that task or that promise. The adapter
therefore violates the contract it overrides — IBackend::cancelPending
(backend.hpp:643-647) says:
Resolves every still-pending completion this backend produced with exc.
[…] After this call, any later setValue / setException on those states is a
no-op (the state is already ready), so in-flight server replies cannot resurrect
a cancelled completion.
For a bind dispatched through this adapter, none of that holds: the state is not
ready, the later resolve is not a no-op, and the completion resolves successfully after cancellation.
Why it matters
~Bridge (bridge.hpp:419) and Bridge::switchBackend (bridge.hpp:1261) both
rely on cancelPending as their one teardown verb. A .then continuation
registered against a Bridge-owned receiver that fires after~Bridge has
already cancelled is the use-after-free family detail::BridgeLifetime /
morph#486 exists to prevent — except that the gate protects calls into the Bridge, not a continuation the caller attached to the returned Completion.
Severity today is limited: grep -rn SynchronousBackendAdapter finds no
production call site on master (only tests/test_backend_registration_surface.cpp,
and prose in tests/net/test_socket_backend.cpp / docs/spec/core/backend.md). #571 proposes making this surface the default path, which is what would turn
this from latent into live.
Verification status
Reproduced. On master @ a020e69c, with clang++ 22.1.8 on Linux, a
standalone program that:
wraps a LocalBackend whose registerModelWithContext blocks until released,
calls adapter.bindModel(...) and attaches .then / .onError,
waits until the wrapped call is provably inside the blocking region,
releases the wrapped call and drains the callback executor.
Real output:
S1 SynchronousBackendAdapter::cancelPending()
after cancelPending(): okRan=0 errRan=0
PASS neither continuation has run yet (the bind is still blocked in the wrapped backend)
after the wrapped bind finished: okRan=1 errRan=0
FINDING the bind Completion resolved SUCCESSFULLY after cancelPending(); the adapter's own
in-flight completions are invisible to cancelPending(), which only forwards to _inner.
okRan=1, errRan=0 is the defect: the success continuation ran after the
cancellation.
Build line (header-only; glaze is the pinned v7.4.0 FetchContent copy):
clang++ -std=c++23 -O1 -w -I include -I <glaze-v7.4.0>/include probe.cpp
What I did NOT verify:
Whether the same hole exists for promoteModel — it goes through the identical dispatch() helper, so it is inferred to, not measured.
Behaviour on the PR qt: move QtWebSocketBackend onto the structural registration surface #585 branch (origin/fix-568-qt-structural-registration @ 49bd5b8b). That branch adds bindWaitPolicy() returning BindWait::kCallerMustNotBlock for this adapter, which prevents the related awaitHandoff hang — but it does not change cancelPending, which is still the
same one-line forward. Read, not run.
Whether any existing test would fail if this were fixed. tests/test_backend_registration_surface.cpp
has adapter cases but none that calls cancelPending.
What would change the verdict
Close it if a maintainer decides the adapter is documented as not owning its
own pending set and IBackend::cancelPending's contract is amended to say
"completions the wrapped backend produced" — in which case the doc comment on IBackend::cancelPending and the adapter's @brief ("Forwards to the wrapped
backend") both need the carve-out written down, because today the base contract
says the opposite.
Track the shared_ptr<Promise> handed to each _control task in a vector<weak_ptr<...>> under the adapter's own mutex — the same snapshot-then-deliver
shape LocalBackend::cancelPending already uses (backend.hpp:1345) — and have cancelPending reject those before (or after) forwarding to _inner. Note the _control task must then tolerate rejecting an already-settled promise, which CompletionState::setException's if (ready) return; already makes safe.
Found while writing the cancellation-entry-point table for #550 (Part 1). Row B6
of that table.
Summary
SynchronousBackendAdapter::cancelPending(include/morph/core/backend.hpp:1039)is a one-line forward to the wrapped backend:
But the two verbs this adapter exists to reshape —
bindModelandpromoteModel— do not produce completions the wrapped backend knows about. Theyproduce a
CompletionwhosePromiseis captured by a task posted to theadapter's own private
StrandExecutor(_control,backend.hpp:1088), viadispatch()atbackend.hpp:1067-1082:Nothing in
cancelPendingreaches that task or that promise. The adaptertherefore violates the contract it overrides —
IBackend::cancelPending(
backend.hpp:643-647) says:For a bind dispatched through this adapter, none of that holds: the state is not
ready, the later
resolveis not a no-op, and the completion resolvessuccessfully after cancellation.
Why it matters
~Bridge(bridge.hpp:419) andBridge::switchBackend(bridge.hpp:1261) bothrely on
cancelPendingas their one teardown verb. A.thencontinuationregistered against a
Bridge-owned receiver that fires after~Bridgehasalready cancelled is the use-after-free family
detail::BridgeLifetime/morph#486 exists to prevent — except that the gate protects calls into the
Bridge, not a continuation the caller attached to the returnedCompletion.Severity today is limited:
grep -rn SynchronousBackendAdapterfinds noproduction call site on
master(onlytests/test_backend_registration_surface.cpp,and prose in
tests/net/test_socket_backend.cpp/docs/spec/core/backend.md).#571 proposes making this surface the default path, which is what would turn
this from latent into live.
Verification status
Reproduced. On
master@a020e69c, withclang++ 22.1.8on Linux, astandalone program that:
LocalBackendwhoseregisterModelWithContextblocks until released,adapter.bindModel(...)and attaches.then/.onError,adapter.cancelPending(make_exception_ptr(runtime_error{"BridgeDestroyedError"})),Real output:
okRan=1, errRan=0is the defect: the success continuation ran after thecancellation.
Build line (header-only; glaze is the pinned v7.4.0 FetchContent copy):
What I did NOT verify:
promoteModel— it goes through the identicaldispatch()helper, so it is inferred to, not measured.origin/fix-568-qt-structural-registration@49bd5b8b). That branch addsbindWaitPolicy()returningBindWait::kCallerMustNotBlockfor this adapter, which prevents the relatedawaitHandoffhang — but it does not changecancelPending, which is still thesame one-line forward. Read, not run.
tests/test_backend_registration_surface.cpphas adapter cases but none that calls
cancelPending.What would change the verdict
own pending set and
IBackend::cancelPending's contract is amended to say"completions the wrapped backend produced" — in which case the doc comment on
IBackend::cancelPendingand the adapter's@brief("Forwards to the wrappedbackend") both need the carve-out written down, because today the base contract
says the opposite.
SynchronousBackendAdapterbecomes a production path, or if a caller is found that attaches a receiver-owned
continuation to an adapter
bindModelcompletion.Sketch of a fix (not implemented here)
Track the
shared_ptr<Promise>handed to each_controltask in avector<weak_ptr<...>>under the adapter's own mutex — the same snapshot-then-delivershape
LocalBackend::cancelPendingalready uses (backend.hpp:1345) — and havecancelPendingreject those before (or after) forwarding to_inner. Note the_controltask must then tolerate rejecting an already-settled promise, whichCompletionState::setException'sif (ready) return;already makes safe.Found while writing the cancellation-entry-point table for #550 (Part 1). Row B6
of that table.