Skip to content

research: write down the real cancellation policy, then test whether stdexec (or std::stop_token, or strand-confinement) removes any of the 54 mutexes #550

Description

@Yaraslaut

Summary

morph has six unrelated cancellation mechanisms and 54 mutex members across 28 headers, and the concurrency spec is explicit that its rules "encode recent fixes to real deadlocks and use-after-frees" (docs/spec/concurrency_and_lifetimes.md:164). This is a research task, not an implementation one: write down what the cancellation policy actually is today, then evaluate whether a sender/receiver model (stdexec, or C++26 std::execution) — or a cheaper intervention — would collapse the mechanism count and the lock count.

Deliverable: a finding in docs/superpowers/findings/, in the style of userver-vs-morph-2026-08-17.md. A recommendation with evidence, not a branch.

Part 1 — what the cancellation policy is today

There is no single policy. There are six mechanisms, and they neither compose nor propagate:

# Mechanism Where Shape
1 CallbackScope / CallbackToken core/callback_scope.hpp Receiver-lifetime + supersede gate. Three verbs (requestStop, reset, destructor), three statuses.
2 IBackend::cancelPending(exception_ptr) core/backend.hpp:458, :890, core/remote.hpp:2107 Bulk cancel-all. Snapshot-then-deliver under _pendingMtx, weak_ptr<CompletionState> tracked.
3 Exception-as-cancellation on Completion BackendChangedError, BridgeDestroyedError "there is no public cancel API on Completion itself" (backend.hpp:544). Cancellation arrives as an error in .onError(...).
4 TimeoutScheduler::cancel(Handle) core/timeout_scheduler.hpp:132, :242 Handle-based, and two different implementations of the same public API with different lifetime semantics (threaded vs. Emscripten).
5 ReplyRouter sweep core/detail/reply_router.hpp:122 Race-resolved against the sweep: "it happens-before the sweep (and the sweep cancels it), or it happens-after".
6 bridge::detail::BridgeLifetime core/bridge.hpp:361 shared_mutex + flag. Exists because (1) is explicitly the wrong tool for gating a call into an object whose destruction it observes — issue #486.

Things worth writing down explicitly, because they are currently only implied:

  • Cancellation does not propagate. Cancelling at the Bridge layer does not reach the backend, the transport, or the server's in-flight work. A cancelled Completion is an abandoned one.
  • Cross-thread stop is advisory. callback_scope.md:194-240 is normative about this: a scope stopped from a thread other than the delivery executor can turn inactive between check and handler body.
  • Nothing blocks until drained, deliberately — a GUI-thread destructor waiting on a pool-thread callback is the self-join deadlock family the spec already warns about.
  • Neither std::stop_token nor std::jthread appears anywhere in include/ or src/. The mechanisms above are all bespoke.

The first half of the research is simply: is this six-mechanism surface the minimum the problem demands, or is it six spellings of two ideas?

Part 2 — the mutex footprint

54 mutex members over 28 headers. Concentration:

Count File
9 core/bridge.hpp — including six at Bridge class scope: _backendMtx, _mtx, _attachMtx, _sessionMtx, _principalMtx, _executeDeadlineMtx
7 net/socket_backend.hpp
7 core/remote.hpp
3 net/socket_server.hpp
2 each journal/journal.hpp, core/strand.hpp, core/observability.hpp, core/executor.hpp, core/backend.hpp

Six mutexes in one class is what makes a documented global lock order load-bearing (Bridge::_mtx before LocalBackend::_regMtx, concurrency_and_lifetimes.md:483). The spec carries a whole section of hazards that exist because of this shape: ReconnectCoordinator holds _mtx across the entire reconnect→activate→bind→replay loop including retry sleeps (:556); the logger's non-recursive mutex self-deadlocks if a sink logs (:578); cancelPending must swap-then-deliver outside the lock or a re-entering callback deadlocks (:370).

Part 3 — the actual question to answer

Test the premise before accepting it. The issue title couples cancellation and mutex count, but they may be largely independent. Classify all 54 locks first:

  • (a) Cancellation/lifetime locks_pendingMtx, BridgeLifetime's shared_mutex, the scheduler's entry map. These a stop-token model could plausibly delete.
  • (b) Shared-mutable-state locks — the journal, the offline queues, the logger sink, the session/principal fields. A sender model does not remove these; only moving the state behind a strand does.
  • (c) Executor-internal locksexecutor.hpp, strand.hpp queue + condvar pairs. These are the executor's job and stay under any model.

If the split lands mostly in (b) and (c), stdexec is the wrong lever and the honest recommendation is "strand-confine the state" — a far cheaper change.

Then evaluate the candidates:

  1. stdexec (NVIDIA reference impl of P2300). Gives structured cancellation that actually propagates: get_stop_token(get_env(receiver)), inplace_stop_source, cancellation that composes through when_all / let_value. Directly addresses "cancellation does not propagate" and mechanisms 1-5 collapsing toward one.
  2. C++26 std::execution. Same model, standardised — arguably worth waiting for rather than taking a large third-party dependency. Check what clang-20/22 (the versions this repo builds against) actually ship.
  3. std::stop_token alone. No dependency, C++20, already available. Could unify mechanisms 1, 4 and 5 without any sender machinery. Likely the highest value-to-risk ratio and should be evaluated as the baseline the others must beat.
  4. Strand-confinement. Push state behind StrandExecutor so it needs no lock at all. Orthogonal to all of the above and possibly the real answer for category (b).

Costs the finding must weigh

  • Public API break. morph's surface is callback-based — Completion, .onSuccess/.onError, Bridge, IBackend. Every ladder example (examples/), the Qt layer and the forms layer sit on it. A sender-based core is not a refactor, it's a new major version.
  • WASM/Emscripten. The single-threaded build is a first-class target — TimeoutScheduler already ships two implementations for it. Whatever is proposed must work there, and stdexec's story on Emscripten needs checking, not assuming.
  • Compile time and dependency weight. stdexec is heavy. morph is header-only in most of include/morph/, so it would land in every consumer TU.
  • The existing spec is an asset. concurrency_and_lifetimes.md (739 lines) and callback_scope.md (313 lines) encode hard-won fixes. Any proposal has to say which documented hazards it eliminates versus merely relocates — a rewrite that trades six known, documented hazards for an unknown set is a regression even if the code is prettier.

Acceptance

A finding document that contains:

  1. A single table of every cancellation entry point in morph and what it actually guarantees — usable as the "cancellation policy" section the spec currently lacks.
  2. All 54 locks classified (a)/(b)/(c), with counts.
  3. A recommendation among the four candidates, with the cheapest viable option named explicitly even if it isn't stdexec.
  4. If stdexec is recommended: a spike on one subsystem (TimeoutScheduler + ReplyRouter is the smallest self-contained pair) measuring compile-time delta, WASM viability, and locks actually removed.

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: coreenhancementNew feature or requesttriage: rescopeReal problem, wrong framing; rewrite before building

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions