Skip to content

core: SynchronousBackendAdapter::cancelPending does not cancel the completions the adapter itself produced #619

Description

@Yaraslaut

Summary

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.
void cancelPending(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:

_control.post(kControlStrand, [shared, op = std::move(op)]() mutable {
    try {
        shared->resolve(op());
    } catch (...) {
        shared->reject(std::current_exception());
    }
});

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:

  1. wraps a LocalBackend whose registerModelWithContext blocks until released,
  2. calls adapter.bindModel(...) and attaches .then / .onError,
  3. waits until the wrapped call is provably inside the blocking region,
  4. calls adapter.cancelPending(make_exception_ptr(runtime_error{"BridgeDestroyedError"})),
  5. 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.
  • Re-open / raise severity if core: remove IBackend's four async twins and retire the prose threading contract #571 lands and SynchronousBackendAdapter
    becomes a production path, or if a caller is found that attaches a receiver-owned
    continuation to an adapter bindModel completion.

Sketch of a fix (not implemented here)

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: coreSubsystem: corebugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions