Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions docs/spec/concurrency_and_lifetimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,8 +408,10 @@ the "work" column below is for a synchronous handler unless it says otherwise.
| `~Bridge` | **G2** for its calls | Calls waiting for a bind and the backend's pending calls end with `BridgeDestroyedError`, posted | **G4** with a `LocalBackend`: the backend is destroyed with the bridge, and drains its strands, so the destructor returns only once the running action has ended. A Task handler is asked to stop first | M |
| `Bridge::switchBackend` | **G2** for the outgoing backend's calls | `BackendChangedError`, posted | The outgoing backend is destroyed inside the switch: with a `LocalBackend`, the switch waits for its running actions | M |
| `Bridge::setExecuteDeadline` | **G0** for calls already made | Applies to calls made after it. When a call's deadline fires, the call ends as in G2, with `ClientTimeoutError` | A synchronous action runs to its end; a Task handler is asked to stop | M |
| A stop on `BridgeHandler::execute(action, stop)`'s token | **G2**, from any thread | `OperationCancelled` is posted to the call's `cbExec` by the thread that requested the stop; the call's own outcome, later, reaches nobody. A token already stopped rejects the call the same way without dispatching it | Asked to stop, not waited for: a Task handler on a `LocalBackend` sees the stop. A synchronous action runs to its end. **On a remote backend the call is only abandoned**: no `cancel` crosses the wire, so the server's handler runs to its end and its reply is dropped (measured with `SimulatedRemoteBackend`). The call stays counted as pending until that reply, as for a fired deadline | M |
| `BridgeHandler::unsubscribe<R>()` | **G1** | A delivery already posted still runs after it returns; no new one is scheduled. Pair it with a `CallbackScope` for G3 | n/a | M |
| `CallbackScope::requestStop()`, `reset()`, `~CallbackScope` | **G3** on the delivery executor; **G1** from another thread | A gated callback posted but not yet run is refused when it runs. From another thread the check and the body are not atomic, so a callback that has passed its check still runs — advisory, and not measured as a race | Never waits for a callback in its body (measured). Stops every call a gated continuation is attached to | M |
| A stop on a coroutine suspended in `co_await completion` | **G2** for the await | The coroutine's resumption with `OperationCancelled` is posted to the executor it suspended on; the await's handlers are refused, so the call's own outcome, later, does not reach it | The call is asked to stop, through the awaiter's scope linked to its stop source: a Task handler on a `LocalBackend` sees the stop (measured). Not waited for. A synchronous action runs to its end; a remote server's handler runs on | M |
| `TimeoutScheduler::cancel` | **G0** on return; **G3** for a callback not yet started, once the loop has run the cancel | The cancel is posted to the loop: on return the callback still holds its captures | Never waits for a callback already running | M |
| `~TimeoutScheduler` | **G4** | Every pending callback is dropped unfired | Returns only once no callback of the scheduler is running | M |
| `LimitPolicy::executeTimeout` (`RemoteServer`) | **G2** for the reply | `err "timeout"` is sent; the handler's own reply, later, is dropped — one reply per call | A synchronous handler runs to its end; a Task handler is asked to stop | M |
Expand All @@ -425,14 +427,9 @@ the "work" column below is for a synchronous handler unless it says otherwise.
- **No cancel crosses the wire.** A remote call is settled locally only; the
server finishes the handler and its reply is dropped. There is no `cancel`
envelope.
- **A `co_await` that is stopped withdraws the await, not the call.** The
coroutine resumes with `OperationCancelled`; the call it was waiting on runs
on, because the awaiter's stop is not linked to the call's stop source. A
continuation attached through a `CallbackScope` *is* linked: stopping the
scope stops the call.
- **A caller has no direct cancel verb on one call.** `Completion` has no
cancel API and `execute()` takes no stop token; the scope link above and the
execute deadline are the only per-call routes.
- **`Completion` has no cancel API of its own.** A call is cancelled through
the token given to `execute(action, stop)`, a stopped scope a continuation is
gated by, a stopped `co_await`, or the execute deadline.

## Gating callbacks on a receiver's lifetime — `CallbackScope`

Expand Down
3 changes: 2 additions & 1 deletion docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,7 @@ is tolerated, as above.
| `registerHandler(binding)` | `void registerHandler(const shared_ptr<HandlerBinding>&)` | Pre-built binding. Same bind rule. |
| `switchBackend` | `void switchBackend(unique_ptr<Backend>)` / `void switchBackend(shared_ptr<IBackend>)` | Tells the new backend its owner and pushes the default session, then issues a re-bind for every live binding. Binds settled before returning decide it: a failure releases what was acquired and rethrows with the old backend and every binding untouched; otherwise it commits — publishes settled ids, leaves bindings with a bind in flight holding their calls, swaps, notifies, moves the reconnect handler, and cancels the old backend's pending calls with `BackendChangedError` (stopping running Task handlers). The `unique_ptr` overload is a template on the concrete backend type and delegates to the `shared_ptr` one. |
| `deregisterHandler` | `void deregisterHandler(const shared_ptr<HandlerBinding>&)` | Supersedes a bind in flight (its reply releases its instance), rejects held calls with `HandlerDestroyedError`, deregisters from the active backend (if bound), resets `currentId` to 0, removes from tracking. |
| `executeVia<Model, Action>` | `Completion<R> executeVia(const shared_ptr<HandlerBinding>&, Action, IExecutor*, function<void(const R&)> onResult = {})` | On the owner. Counts the call pending and arms the execute deadline when it is made; dispatches now when no bind is in flight, else holds it until the bind settles (a failed bind rejects it with the bind's error; a never-attached `AllowShared` binding with `"handler not bound"`). Attaches the default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records a journal `LogEntry` for loggable actions on both success (`Outcome::Succeeded`) and a throwing `Model::execute` (`Outcome::Failed`, rethrown unchanged); a failure to serialise the result or to append the success entry happens after the mutation committed and rejects the completion with `morph::model::ActionRecordingError` instead. Dispatches through `IBackend::executeInto`, handing the backend a `detail::BridgeSink<R>` that is simultaneously the caller's typed completion state and the backend's settle sink. The bridge-side work a result triggers (`onResult`, the subscription fan-out) runs on the owner, gated on the bridge's `CallbackToken`. A call whose handler returns a `Task` carries a `StopSource`, requested by the deadline, by a `CallbackScope` a callback was attached through, and by the backend's `cancelPending`. |
| `executeVia<Model, Action>` | `Completion<R> executeVia(const shared_ptr<HandlerBinding>&, Action, IExecutor*, function<void(const R&)> onResult = {})` | On the owner. Counts the call pending and arms the execute deadline when it is made; dispatches now when no bind is in flight, else holds it until the bind settles (a failed bind rejects it with the bind's error; a never-attached `AllowShared` binding with `"handler not bound"`). Attaches the default session. On `LocalBackend`, rejects an action whose `ActionValidator::ready` returns `false` with `morph::model::ValidationError` via `onError`, before `Model::execute` runs. Records a journal `LogEntry` for loggable actions on both success (`Outcome::Succeeded`) and a throwing `Model::execute` (`Outcome::Failed`, rethrown unchanged); a failure to serialise the result or to append the success entry happens after the mutation committed and rejects the completion with `morph::model::ActionRecordingError` instead. Dispatches through `IBackend::executeInto`, handing the backend a `detail::BridgeSink<R>` that is simultaneously the caller's typed completion state and the backend's settle sink. The bridge-side work a result triggers (`onResult`, the subscription fan-out) runs on the owner, gated on the bridge's `CallbackToken`. A call whose handler returns a `Task` carries a `StopSource`, requested by the deadline, by a `CallbackScope` a callback was attached through, by a `co_await` of its completion that is stopped, by the token given to `BridgeHandler::execute(action, stop)`, and by the backend's `cancelPending`. |
| `setDefaultSession` | `void setDefaultSession(session::Context)` | Installs default session context; also pushes it to the active backend via `IBackend::setSession` so control envelopes (register/attach/assign/deregister) carry it too, not only `execute`. |
| `defaultSession` | `session::Context defaultSession() const` | Returns snapshot of default session. |
| `setExecuteDeadline` | `void setExecuteDeadline(std::chrono::milliseconds)` | Opt-in client-side execute deadline; `0` (the default) disables it. Lazily creates the backing `TimeoutScheduler`, on a private `exec::IoLoop` with one thread, on first enable. |
Expand All @@ -886,6 +886,7 @@ is tolerated, as above.
| ctor (custom binding) | `BridgeHandler(Bridge&, IExecutor*, shared_ptr<HandlerBinding>)` | Registers the pre-built binding the same way. |
| dtor | `~BridgeHandler()` | On the owner. Deregisters via `Bridge::deregisterHandler` (rejecting held calls with `HandlerDestroyedError`), but only if the bridge's `CallbackToken` is still active; a no-op if the `Bridge` was already destroyed. |
| `execute<Action>` | `Completion<R> execute(Action)` | Typed dispatch through the bridge; held until the handler's bind settles when one is in flight. For a shared handler, a payload-/result-keyed action's attach or promote step never throws synchronously — a backend refusal (e.g. `LimitPolicy::maxLiveModels`) resolves the returned `Completion` via `.onError(...)`. |
| `execute<Action>` (cancellable) | `Completion<R> execute(Action, core::async::StopToken stop)` | As `execute(Action)`, with a caller's cancel. A stop on `stop` before the call settles rejects the `Completion` with `core::async::OperationCancelled` — posted from the thread that requested the stop — and requests stop on the call's `StopSource`, so a Task handler on a `LocalBackend` sees it; a synchronous handler runs to its end, and a remote server's handler runs on, since no cancel crosses the wire. The call stays pending until the backend settles it, as for a fired deadline. A token already stopped rejects the call without dispatching it. See the cancellation policy in [concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md#cancellation-policy). |
| `executeJson` | `Completion<string> executeJson(string_view actionType, string_view bodyJson)` | Type-erased dispatch through `ActionExecuteRegistry`. |
| `subscribe<R>(cb)` | `void subscribe(function<void(R)>)` | Fire `cb` whenever an `R` is produced on the attached instance. |
| `subscribe<R>(scope, cb)` | `void subscribe(CallbackScope const&, function<void(R)>)` | As above, gated on the scope's liveness and stop state ([callback_scope.md](callback_scope.md)). Dead sinks are refused, not pruned. |
Expand Down
31 changes: 17 additions & 14 deletions docs/spec/core/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ by `std::make_shared`, which every site in the tree does
| `onErrAttached` | `std::atomic<bool>` | `cbExec`, or a synchronous taker | Suppresses orphan logging; set by an `onError` attach on a state with an executor, and by `bridge::detail::takeSettled` |
| `presumedOwnerThread`, `deliveredOn` | `std::atomic<std::thread::id>` | — | The debug check of an attach made outside every executor's task; see [Thread safety](#thread-safety) |
| `cbExec` | `::morph::exec::IExecutor*` | set before publication | The owner; may be `nullptr` |
| `stopSource`, `stopLinks` | | `cbExec` | The call's stop source and the scope links relaying to it; released by `deliver()` |
| `stopSource`, `stopLinks` | | `cbExec` | The call's stop source, and the links relaying a stop to it — a scope's, an await's, or a caller's cancel token, which also settles the call; released by `deliver()` |

**Setting a value or exception.** `setValue(T)` and `setException(exception_ptr)`
are called by the producer, on any thread. The first claims `settled` with an
Expand Down Expand Up @@ -664,16 +664,19 @@ future/promise or a monadic async type. Its scope is narrow by design:
`T → U` mapping 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 *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](#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](#lifetime-and-stop-gating). The distinction is
sharp and deliberate: a `CallbackScope` says "do not hand me this result",
never "stop producing it". Work-side cancellation is not offered at all.
- **No cancel API on `Completion` itself.** A `Completion` cannot cancel the
operation it reports on. The call's producer can link a cancel to it: a
`Bridge` call whose handler returns a `Task` carries a stop source, which a
stopped `CallbackScope` a callback was attached through, a stopped
`co_await`, the execute deadline and the token given to
`BridgeHandler::execute(action, stop)` all request. That token also settles
the call with `core::async::OperationCancelled`; the deadline settles it with
`ClientTimeoutError`. A synchronous handler, or a server across the wire, is
not reached by any of them: the token and the deadline settle the state
early, and the work runs on underneath (see
[Client-side execute deadline](#client-side-execute-deadline) and the
cancellation policy in
[concurrency_and_lifetimes.md](../concurrency_and_lifetimes.md#cancellation-policy)).
- **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's `onOk`/`onErr` are vectors, so repeated `then()`/`onError()`
Expand All @@ -694,9 +697,9 @@ state; the log is emitted only when the state itself is finally destroyed with a

## Out of scope

- Work cancellation — there is no mechanism to cancel an outstanding
*operation*. Stopping *delivery* of its result is
[`CallbackScope`](callback_scope.md); the two are different things.
- A cancel on the `Completion` handle — cancelling the operation belongs to
whoever produced it (see [Limitations](#limitations)). Stopping *delivery*
of its result is [`CallbackScope`](callback_scope.md).
- Multiple values — `Completion<T>` is a single-result primitive.
- Synchronous blocking — there is no `wait()` or `get()`; the API is
callback-only.
Expand Down
18 changes: 14 additions & 4 deletions docs/spec/core/coroutines.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,20 @@ a stop callback for the duration of the suspension:
executor it suspended on, or on the completion's executor.
- **A token already stopped at `co_await`** resumes with
`OperationCancelled` without attaching anything to the completion.

The completion itself is not cancelled: it is still settled by whatever
produces it, and its other handlers still run. Cancellation withdraws this one
await.
- **A stop also stops the call.** The await owns the call it waits on, the way
a continuation attached through a `CallbackScope` does: the awaiter's scope
is linked to the call's stop source when the handlers are attached
(`CompletionState::linkStop`), so the stop that withdraws the await — or the
frame destroyed while suspended — asks the call to stop too. A stop that
lands before the handlers are attached, a token already stopped included,
is relayed to the call on the completion's executor. Only a call that
carries a stop source can be stopped: a `Bridge` call whose handler returns
a `Task`, on a `LocalBackend`. Its handler sees `OperationCancelled` at its
next stop-aware `co_await`.

The completion itself is still settled by whatever produces it — a stopped
Task handler rejects it with `OperationCancelled` — and its other handlers
still run; the withdrawn await's own handlers do not.

### `spawn` — the entry point from non-coroutine code

Expand Down
38 changes: 38 additions & 0 deletions include/morph/core/bridge.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <cassert>
#include <chrono>
#include <concepts>
#include <core/async/Cancellation.hpp>
#include <core/async/StopToken.hpp>
#include <deque>
#include <exception>
Expand Down Expand Up @@ -2226,6 +2227,43 @@ class BridgeHandler {
}
}

/// @brief Dispatches @p action as `execute(action)` does, cancellable through @p stop.
///
/// A stop on @p stop before the call settles cancels it: the returned
/// `Completion` is rejected with `core::async::OperationCancelled`, posted
/// to the GUI executor from the thread that requested the stop, and the
/// call's stop source is asked to stop. A handler returning a `Task`, on a
/// `LocalBackend`, sees that stop at its next stop-aware `co_await`; a
/// synchronous handler runs to its end, and a remote server's handler runs
/// on, since no cancel crosses the wire. Either way the call's own outcome,
/// later, reaches nobody. A token already stopped rejects the call the same
/// way without dispatching it.
///
/// The call stays counted as pending until the backend settles it, as a
/// call the execute deadline settled does.
///
/// @tparam Action Concrete action type registered with `BRIDGE_REGISTER_ACTION`.
/// @param action Action to execute (moved into the dispatch).
/// @param stop The caller's cancel. Observed until the call settles; a
/// token that can never be stopped makes this `execute(action)`.
/// @return Completion that resolves on the GUI executor, or rejects with
/// `core::async::OperationCancelled` once @p stop is stopped.
template <typename Action>
::morph::async::Completion<typename ::morph::model::ActionTraits<Action>::Result> execute(
Action action, ::core::async::StopToken stop) {
using R = ::morph::model::ActionTraits<Action>::Result;
if (stop.stop_requested()) {
auto [cancelled, promise] = ::morph::async::Completion<R>::makeSettleable(_guiExec);
promise.reject(std::make_exception_ptr(::core::async::OperationCancelled{}));
return std::move(cancelled);
}
auto completion = execute(std::move(action));
// Never empty: every path of `execute(action)` returns a completion
// built on a state, a rejected one included.
completion.state()->linkCancel(std::move(stop));
return completion;
}

/// @brief Attaches (or re-points) this handler to the instance for @p key.
///
/// Creates the instance if no live instance holds @p key, otherwise joins the
Expand Down
Loading
Loading