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
49 changes: 33 additions & 16 deletions docs/spec/concurrency_and_lifetimes.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,20 @@ that bounded wait into an unbounded one. Four dispositions, by site:
morph#571 deleted the twins, so there is no such contract left to state — but
the three sites name `exec::detail::inlineExecutor()` as the delivery
executor, which reproduces the old delivery thread exactly: the continuation
runs wherever the backend settled. **The window is therefore unchanged, not
closed.** `QtWebSocketBackend` is still safe for the reason it always was: it
runs wherever the backend settled.

**Since morph#588 the window is closed for a `Bridge` that was given an
executor, and unchanged for one that was not.** The `bindModel`/
`promoteModel` argument is still `inlineExecutor()` — deliberately, because a
reply that settles inside the dispatch frame must reach `parkIfInFrame`
there, or `registerHandler()` stops being synchronous and `awaitHandoff`
deadlocks against its own executor's thread. What moved is the *late* reply,
the only one that has a thread left to choose: `detail::deliverLate` posts it
to the `bridgeExec` the constructor was given, so for an embedder whose
executor runs on the thread that also runs `~Bridge`, the check and the
destructor are two tasks on one thread and cannot interleave at all. With the
default null executor the delivery is inline and the window is exactly what
it was. `QtWebSocketBackend` is still safe for the reason it always was: it
must itself be used from the Qt event loop thread and settles every reply
from `onTextMessage` on that same thread, so the check and the use cannot
straddle a destructor. Gating these instead would make `~Bridge` block behind
Expand All @@ -344,27 +356,32 @@ that bounded wait into an unbounded one. Four dispositions, by site:

**What changed with the removal is who could get it wrong, not whether it can
be wrong.** A backend that settles a `bindModel` completion on its own
transport thread would still reopen morph#486's use-after-free here; the
difference is that the delivery thread is now a value one call site produces
rather than an obligation on fifteen backend authors, so closing it is a
change in one place. That change — giving `Bridge` an executor of its own —
is morph#588 and has not been made.
transport thread reopens morph#486's use-after-free here for a bridge with no
`bridgeExec`; the difference morph#571 made is that the delivery thread is a
value one call site produces rather than an obligation on fifteen backend
authors, so closing it was a change in one place. morph#588 made it: the
choice is a constructor argument, and the residual exposure is the embedder's
own — supplying an executor on a thread unrelated to teardown satisfies the
type and closes nothing, which is stated where the argument is documented
rather than left to be discovered.

The structural surface that replaces these four hooks —
`IBackend::bindModel`/`promoteModel` — takes the executor the continuation is
delivered on as an argument, so the delivery thread is chosen by the caller,
which knows what its own teardown looks like, instead of by the backend, which
does not. `Bridge` now reaches it at all four sites (morph#568). **That does
not close the window above, and morph#568 does not claim it does**: `Bridge`
owns no event loop, so the executor it names is
does not. `Bridge` now reaches it at all five sites (morph#568, morph#615).
**That does not close the window above, and morph#568 does not claim it
does**: `Bridge` owns no event loop, so the executor it names is
`exec::detail::inlineExecutor()` — "deliver wherever you settled", which is
what the prose contract already required. What changed is where the decision
lives: one value produced at four `Bridge` call sites, rather than a
documented obligation on every `IBackend` implementor. Closing the window
means giving `Bridge` an executor bound to the thread that runs `~Bridge` and
naming that instead; nothing in the morph#522 set does that. See
[core/backend.md](core/backend.md#the-structural-registration-surface--bindmodel-and-promotemodel)
and morph#522.
lives: one value produced at five `Bridge` call sites, rather than a
documented obligation on every `IBackend` implementor. morph#588 then gave
`Bridge` an executor of its own and used it for the late replies — not in
place of the `inlineExecutor()` argument, which the in-frame settle needs, so
the two cases are now told apart by the handoff rather than by the executor.
See
[core/backend.md](core/backend.md#the-structural-registration-surface--bindmodel-and-promotemodel),
[core/bridge.md](core/bridge.md) and morph#522.

`switchBackend()` and `whenBound()` were audited for the same shape and do not
have it. Both are ordinary synchronous member functions called by the bridge's
Expand Down
45 changes: 29 additions & 16 deletions docs/spec/core/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,25 +311,37 @@ event-loop thread passes that thread's executor and the two-step
check-then-dereference can no longer straddle a destructor, by construction
rather than by the backend author having read a `@note`.

**And `Bridge` is not yet that caller.** Its four dispatch sites all name
`exec::detail::inlineExecutor()`, which runs the continuation on whichever
thread the backend settled on — deliberately the *old* delivery thread, so
morph#568 and morph#571 change no observable threading. `Bridge` owns no event
loop and has no thread of its own to name. So the window morph#486 describes is
**unchanged, not closed**: the decision moved, the value did not. For
**`Bridge` is that caller for half of it, since morph#588.** Its five dispatch
sites still name `exec::detail::inlineExecutor()` on the `bindModel`/
`promoteModel` call itself, and that is now a decision rather than an absence:
an inline settle has to reach `Bridge::detail::parkIfInFrame` *inside* the
dispatch frame, because that is what keeps `registerHandler()` synchronous for
a backend that binds inline and what stops `detail::awaitHandoff` waiting on a
task only the waiting thread could run. What morph#588 added is an executor for
the other case — a reply that arrives after the dispatch frame has gone, which
is the only one with a thread left to choose. `Bridge`'s constructor takes an
optional `bridgeExec`, `detail::deliverLate` routes exactly those replies to
it, and a null one (the default) runs them inline, where they ran before.

So the window morph#486 describes is **closed for an embedder that supplies an
executor whose thread also runs `~Bridge`** — the continuation and the
destructor are then two tasks on one thread and cannot interleave — and
**unchanged for one that does not**, which is every caller that has not been
updated. What is no longer true is that `Bridge` has nothing to name. For
`QtWebSocketBackend` the safety is the same by-construction safety it always
had — it must itself be used from the Qt event loop thread and settles every
reply from `onTextMessage` on that same thread, so the check and the use cannot
straddle a destructor. Its two non-reply paths do not weaken this either: a
disconnected or no-op bind settles inline, inside the caller's own frame (which
`Bridge::detail::parkIfInFrame` exists to handle), and `cancelPending` settles
the remainder from `~Bridge` itself, which is not a *concurrent* destructor. A
future backend that replied on its own transport thread would still reopen
morph#486. Giving `Bridge` an executor of its own — which would change the
window rather than merely move the decision — is **morph#588**, and is
deliberately not part of morph#571: the guarantee this surface makes structural
is a guarantee about *backends*, and for `Bridge`-mediated calls the delivery
thread remains what the backend chose.
future backend that replied on its own transport thread reopens morph#486 for
a `Bridge` constructed without a `bridgeExec`, and does not for one constructed
with a suitable one. That split is the whole of what morph#588 claims: the
guarantee this surface makes structural is a guarantee about *backends*, and
for `Bridge`-mediated calls the delivery thread is now the embedder's choice
rather than the backend's — a contract one embedder can satisfy, instead of one
every backend author must remember.

`tests/test_backend_registration_surface.cpp` pins this: the backend settles
from a thread that is asserted to be *not* the caller's, the caller's executor
Expand Down Expand Up @@ -692,11 +704,12 @@ The executor those call sites name is **`exec::detail::inlineExecutor()`**, whic
runs the continuation on the thread that settled it. That is deliberately the
*old* delivery thread, so neither morph#568 nor morph#571 changes observable
*threading*: `Bridge` owns no event loop and has no thread of its own to name.
Making it name a real one is the step that would turn the structural guarantee
into a behaviour change, and it belongs to whichever ticket gives `Bridge` such
an executor — see
morph#588 left that argument alone — an inline settle must still be delivered
inline, or `registerHandler()` stops being synchronous — and gave `Bridge` an
optional executor for the replies that arrive *after* the dispatch frame
instead. See
[How the threading contract becomes structural](#how-the-threading-contract-becomes-structural)
and morph#588.
and [bridge.md](bridge.md), "The bridge's own executor".

## Error types

Expand Down
71 changes: 69 additions & 2 deletions docs/spec/core/bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ that only know action names at runtime.
- [Architecture overview](#architecture-overview)
- [`HandlerBinding`](#handlerbinding)
- [`Bridge`](#bridge)
- [The bridge's own executor](#the-bridges-own-executor)
- [`BridgeHandler<Model>`](#bridgehandlermodel)
- [Registration readiness — `isBound()` / `whenBound()`](#registration-readiness--isbound--whenbound)
- [`ActionExecuteRegistry`](#actionexecuteregistry)
Expand Down Expand Up @@ -425,6 +426,62 @@ now gets (see [callback_scope.md](callback_scope.md)); it uses only the liveness
half — it never calls `requestStop()`, so its tokens go inactive only when the
`Bridge` is destroyed.

### The bridge's own executor

`Bridge`'s constructor takes an optional second argument, `IExecutor*
bridgeExec` (morph#588). Every other completion in the framework is delivered
on an executor its caller named — `BridgeHandler` supplies `guiExec`,
`executeVia` takes a `cbExec`. The registrations the bridge issues *on its own
behalf* had no such executor, and its five dispatch sites
(`registerHandlerImpl`, `attachHandlerAsync`, `ensureBoundAsync`,
`assignHandlerPrimary`, `rebindThroughSurface`) named
`exec::detail::inlineExecutor()` instead: "deliver wherever the backend
settled", written as a value rather than as a sentence in a doc comment.

**What it is used for, and what it is not.** Exactly one thing: a registration
reply that arrives *after* its dispatching frame has closed the
`detail::AsyncDispatchHandoff` window (`detail::deliverLate`). A reply that
settles while the dispatch call is still on the stack is parked by
`detail::parkIfInFrame` and published by the dispatching frame itself, on the
dispatching thread, whatever `bridgeExec` says.

The `bindModel`/`promoteModel` calls therefore keep naming `inlineExecutor()`.
That is a decision, not an omission left over from morph#568, and two things
break if it is changed:

- **`registerHandler()` stops being synchronous.** For every backend that binds
inline — `LocalBackend`, `SimulatedRemoteBackend`, any `kCallerMayBlock`
backend — the settle would become a task queued on `bridgeExec` rather than a
callback on this stack, so `claimHandoff` would find nothing parked and the
caller would get an unbound handler from a call that has always returned a
bound one.
- **A `kCallerMayBlock` backend deadlocks.** `detail::awaitHandoff` stops the
dispatching thread until the reply is parked. If the reply is instead a task
on `bridgeExec` and the dispatching thread *is* the executor's thread — a GUI
embedder passing its GUI executor, which is the intended use — nothing will
ever run that task.

**What the caller must guarantee.** `bridgeExec` is borrowed: it must outlive
the bridge *and* every registration still in flight when the bridge is
destroyed, because a late reply can land after `~Bridge` (the same requirement
`BridgeHandler`'s `guiExec` already carries).

**What it buys, stated exactly.** The morph#486 window in these callbacks is
"check `CallbackToken::active()`, then touch the bridge". It is closed only if
`bridgeExec` runs its tasks on a thread that cannot run `~Bridge` concurrently
— for a Qt embedder, the GUI thread that both owns the `Bridge` and pumps the
executor; the callback and the destructor are then two tasks on one thread and
cannot interleave. An executor on some *other* thread satisfies the type and
closes nothing. It makes nothing worse either: the callbacks' existing
`CallbackToken`/`detail::BridgeLifetime` gates are unchanged, and with the null
default the delivery is inline, byte for byte the pre-morph#588 behaviour.

`tests/test_async_registration.cpp` pins all three halves: a late reply is
queued on the executor and publishes nothing until it is drained (restoring
inline delivery fails that case), an inline bind and a keyed attach still
publish before `registerHandler`/`attachHandler` returns even when the executor
never runs, and a bridge constructed without one behaves as it always did.

## `BridgeHandler<Model>`

RAII handle. Registers a `HandlerBinding` on construction, deregisters on
Expand Down Expand Up @@ -839,7 +896,17 @@ never reach the callback body, `attachHandlerAsync`'s out-of-frame success
callback is free to re-acquire `_attachMtx` for the two `std::string` fields it
publishes (`HandlerBinding::contextKey`/`primary`, which every other reader
takes that lock for); `ensureBoundAsync`'s publishes only the atomic
`currentId` and needs no lock at all.
`currentId` and needs no lock to store it.

Both out-of-frame callbacks reach those publishes through one private helper,
`publishLateBindReply`, which holds the four steps they share — the liveness
check, the binding lock, the stale-backend comparison under `_attachMtx`, and
the single `onDone` outside it — and takes what to publish as a callable. It is
a template rather than a `std::function` parameter so the late path
type-erases and allocates nothing. `assignHandlerPrimary`'s continuation
deliberately does not use it: having no `onDone`, it drops a stale reply
silently instead of reporting it, and that difference is intended rather than
incidental.

`whenBound()` synchronises on the *binding's* `registrationMtx`, never on a
`Bridge` mutex, and never holds it across a callback: the resolver swaps the
Expand Down Expand Up @@ -948,7 +1015,7 @@ make teardown order-independent.)

| Member | Signature | Notes |
|---|---|---|
| ctor | `explicit Bridge(unique_ptr<IBackend>)` | Installs reconnect handler on the backend, then pushes the (initially empty) default session via `setSession`. |
| ctor | `explicit Bridge(unique_ptr<IBackend>, IExecutor* bridgeExec = nullptr)` | Installs reconnect handler on the backend, then pushes the (initially empty) default session via `setSession`. `bridgeExec` is where a registration reply that arrived after its dispatch frame is delivered; null (the default) delivers it inline, exactly as before morph#588. See [The bridge's own executor](#the-bridges-own-executor). |
| dtor | `~Bridge()` | Clears the active backend's reconnect handler, then cancels all pending completions with `BridgeDestroyedError`. |
| `registerHandler<Model>` | `shared_ptr<HandlerBinding> registerHandler()` | Default factory. Dispatches `IBackend::bindModel`; see `backend.md`. |
| `registerHandler(binding)` | `void registerHandler(const shared_ptr<HandlerBinding>&)` | Pre-built binding. Same async-preferring behavior. |
Expand Down
28 changes: 28 additions & 0 deletions docs/spec/testing_strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,34 @@ file scope, not inside the file's anonymous namespace with its other local
helpers — Glaze's reflection needs external linkage to mangle the type name,
the same requirement `tests/fuzz/`'s harness fixtures document.

### Allocation census (`bench_dispatch_allocations.cpp`, target `morph_bench_alloc`)

A second binary under the same option, and deliberately **not** a second case
in `morph_bench`: it replaces the global `operator new`/`delete`, which is
process-wide and would perturb any other measurement sharing the binary. It
counts the heap allocations one `Ping -> Pong` round trip costs through
`LocalBackend` — 50 warm-up calls excluded, every call waited out, no JSON and
no socket — and prints the total, the bytes, and (with `--attribute`) the size
of every allocation in one steady-state call.

It exists because morph#572 is scoped by a number that three later pull
requests invalidated, and re-deriving such a number from a prose description of
how it was once taken is how a fix ends up built against a figure nobody
re-checked.

**It is an instrument, not a control, and the distinction is the point here.**
It is not registered with ctest and asserts nothing unless `--budget=<n>` is
passed: an allocation count is standard-library and allocator specific, so a
ceiling that holds on libstdc++ would be wrong on libc++ or MSVC, and a gate
that cannot be satisfied everywhere is worse than none. A green run of it
proves nothing; the number it prints is the output. Turning it into a CI gate
means giving it a per-toolchain budget first.

Measured with it on `f24e225a`, x86-64 Linux, GCC 16.2.1 / libstdc++, `-O2
-DNDEBUG`: **20.9 allocations and 1995 bytes per local round trip**, 21
allocations in the recorded steady-state call. See morph#572 for the
per-line attribution and what it says about that ticket's scope.

## Adversarial cross-socket run (`tests/qt/test_qt_websocket_adversarial.cpp`)

Built under the existing `MORPH_BUILD_QT=ON` option (no new option — it's one
Expand Down
Loading
Loading